Match full reference demo and validate original missions, transitions and sprite rendering
This commit is contained in:
1 parent
967ba69ee8
commit
0844f6dffa
21 files changed
+3984
-219
No files matched your search
@@ -0,0 +1,138 @@
|
||||
# Original campaign validation
|
||||
|
||||
Local checks on 2026-10-10 reached mission completion for all 27 original waves
|
||||
at each of the four difficulties. These 108 runs are assisted inspection fixtures,
|
||||
not ordinary player victories and not per-wave C++ parity proofs. Original game
|
||||
data, reference recordings, profiles and CSV reports remain under ignored local
|
||||
directories.
|
||||
|
||||
The separate, complete `DEMO1G1_REC` comparison matches the rebuilt C++ engine
|
||||
across all 1,889 available simulation steps. Captured fields are the RNG seed,
|
||||
player coordinates/banking/shields/credits, ordered enemy asset/position/health/
|
||||
animation/movement coordinates, and ordered projectile asset/position/hostility.
|
||||
The recording starts in sector 1 wave 3 and ends before the boss. Inventory,
|
||||
internal AI counters, terrain damage, effects, audio samples and presentation are
|
||||
outside this gameplay trace; this does not establish full-game parity.
|
||||
|
||||
## Assisted campaign fixture
|
||||
|
||||
`tools/campaign_check.rs` gives the pilot the original arsenal and five mega
|
||||
bombs, fires continuously, aims horizontally at visible enemies while preferring
|
||||
bosses, cycles specials every 180 simulation steps and presses the bomb control
|
||||
every 480 steps. Before each step it replenishes energy and super-shield values
|
||||
to 100 each. Inputs still use the game input path. Enemy definitions, damage,
|
||||
spawn timing, RNG and mission outcomes are not replaced. A run stops at the
|
||||
reported outcome or its 12,000-step inspection limit.
|
||||
|
||||
The final run visited every spawn record, observed every expected boss instance
|
||||
and finished with no remaining enemies. Its local CSV is
|
||||
`.reference/campaign-validation.csv` (SHA-256
|
||||
`8702d190a039239de154e09ebbb5972f6d7ffe07475d7d47b199b53efb3a811a`).
|
||||
|
||||
| Difficulty | Completed waves | Steps per wave | Enabled spawns | Boss instances seen | Nonpositive last health | Positive last health |
|
||||
| --- | ---: | --- | ---: | ---: | ---: | ---: |
|
||||
| 0 | 27 | 4,601–4,873 | 3,751 | 88 | 53 | 35 |
|
||||
| 1 | 27 | 4,607–4,873 | 3,751 | 88 | 53 | 35 |
|
||||
| 2 | 27 | 4,651–5,079 | 6,259 | 88 | 36 | 52 |
|
||||
| 3 | 27 | 4,650–5,082 | 8,668 | 88 | 37 | 51 |
|
||||
|
||||
The 108 runs cover 513,002 simulation steps, 22,429 enabled spawns and 352 boss
|
||||
appearances. Boss retirement counts classify the health observed immediately
|
||||
before removal: 179 nonpositive and 173 positive. Positive health commonly
|
||||
indicates a boss leaving the screen; damage and removal within the same step
|
||||
can also affect this classification. These counts are not an exact death-event
|
||||
audit. The harness deliberately allows the original wave completion condition
|
||||
after all spawned ships have died or left, rather than requiring every boss to
|
||||
be killed.
|
||||
|
||||
Additional opt-in tests exercised all 15 original player weapon definitions with
|
||||
live mission targets and observed emitted projectiles. This verifies availability
|
||||
and basic firing paths, not exact weapon effects. Progression checks exercise
|
||||
training's four-wave route, each normal 27-wave route, difficulty promotion,
|
||||
campaign resets and the training reward. They call the progression API directly
|
||||
and bypass combat; end cinematics and menu transitions need separate checks.
|
||||
|
||||
Run the four original-data integration tests from the project directory:
|
||||
|
||||
```sh
|
||||
RAPTOR_CAMPAIGN_REPORT=.reference/campaign-validation.csv \
|
||||
cargo test --release --no-default-features --test campaigns -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Set `RAPTOR_TEST_DATA` if the original catalog is elsewhere. Set
|
||||
`RAPTOR_REFERENCE_DEMO1` to use a different full C++ reference trace. Without
|
||||
`--ignored`, these tests do not require or distribute original assets.
|
||||
|
||||
The reporting helper is also a standalone Rust CLI. Build the library, supply
|
||||
its resulting `libraptor_replica-*.rlib` path to `rustc --extern`, and retain the
|
||||
dependency directory:
|
||||
|
||||
```sh
|
||||
cargo build --release --no-default-features --lib
|
||||
rustc --edition=2024 tools/campaign_check.rs \
|
||||
--extern raptor_replica=target/release/deps/libraptor_replica-HASH.rlib \
|
||||
--extern anyhow=target/release/deps/libanyhow-HASH.rlib \
|
||||
-L dependency=target/release/deps -o .reference/campaign-check
|
||||
.reference/campaign-check data
|
||||
```
|
||||
|
||||
Its optional second argument selects one difficulty, for example
|
||||
`.reference/campaign-check data 3`.
|
||||
|
||||
## Reproduce the complete demo comparison
|
||||
|
||||
Rebuild the retained C++ source before capturing a reference. The local debug
|
||||
build used `.reference/cpp-rng-build`; use the available CMake executable if it
|
||||
is outside `PATH`. Prepare an isolated legacy profile with `raptorsetup`, or
|
||||
copy an existing `SETUP.INI` into `.reference/full-demo-profile/Raptor/` before
|
||||
running the following commands. The original executable requires `data/` as its
|
||||
working directory. A complete reference takes about 81 seconds at 70/3 Hz.
|
||||
|
||||
```sh
|
||||
cmake --build .reference/cpp-rng-build --parallel 4
|
||||
(
|
||||
cd data
|
||||
XDG_DATA_HOME="$PWD/../.reference/full-demo-profile" \
|
||||
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \
|
||||
RAPTOR_REFERENCE_TRACE="$PWD/../.reference/cpp-demo1-full.jsonl" \
|
||||
RAPTOR_REFERENCE_TICKS=1889 \
|
||||
../.reference/cpp-rng-build/bin/raptor PLAY demos/DEMO1G1_REC
|
||||
)
|
||||
cargo run --no-default-features -- \
|
||||
--replay DEMO1G1_REC --ticks 1889 --trace .reference/rust-demo1-full.jsonl
|
||||
python3 tools/compare_replays.py \
|
||||
.reference/cpp-demo1-full.jsonl .reference/rust-demo1-full.jsonl
|
||||
```
|
||||
|
||||
The comparator reports `Identical gameplay snapshots across 1889 simulation
|
||||
steps.` and rejects unequal lengths, repeated tick numbers or the first differing
|
||||
field. The local C++ trace SHA-256 is
|
||||
`6f0378dbe4d819b44de86dc22822d2e70cb65546e6aed89dd45a44a8eb3d02fe`.
|
||||
|
||||
This comparison found and repaired beam damage before player-follow artwork,
|
||||
raw recorded positions beyond ordinary input bounds, low-shield equipment loss
|
||||
and automatic selection cycling, and the last authored kamikaze leg's transition
|
||||
into the player chase. Portable synthetic regressions in `tests/gameplay.rs`
|
||||
cover those rules without original pixels or recordings.
|
||||
|
||||
## Remaining gameplay validation
|
||||
|
||||
The catalog inspection found no wave completion blocker under the assisted
|
||||
fixture. Source inspection also found and repaired three original weapon rules,
|
||||
with four additional portable regressions:
|
||||
|
||||
- Energy Grab damages only airborne ships by one point, drains the original
|
||||
resistance counter and disables susceptible enemies' firing. Immunity and
|
||||
continued suppression after animation updates have separate coverage.
|
||||
- Ground Bomb damages terrain before checking a ground enemy and applies the
|
||||
original left/up/right neighbor effects, including the preceding-row pass.
|
||||
One test verifies simultaneous terrain and ground-enemy damage.
|
||||
- Mega Bomb applies its original 50 enemy damage, clears hostile shots and
|
||||
subtracts 20 health only from visible destructible terrain. A test covers
|
||||
distant tiles, inert terrain, destroyed tiles and the final visible tile.
|
||||
|
||||
A longer record set, C++ traces through bosses and departure/death sequences,
|
||||
ordinary pilot playthroughs, all weapon effects, shield recharging and complete
|
||||
save/menu/cinematic routes are still needed for full acceptance. The complete
|
||||
demo match and assisted campaign coverage should be retained as regression
|
||||
evidence while those gaps are resolved.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Original first mission: ordinary-input victory
|
||||
|
||||
The opt-in `tests/first_mission.rs` check completes `base:sector_1_wave_1`
|
||||
through its original boss with a fresh pilot on difficulties 1 and 2. It uses
|
||||
the locally installed original catalog and the production `World::step` path.
|
||||
Both runs start with the default forward guns, shield 100, no super shield,
|
||||
no special weapon, and zero credits. No extra equipment is required.
|
||||
|
||||
Run it with:
|
||||
|
||||
```sh
|
||||
RAPTOR_TEST_DATA=/absolute/path/to/data cargo test --release --no-default-features --test first_mission -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Without `RAPTOR_TEST_DATA`, it reads this checkout's `data/` directory. The
|
||||
test is ignored in the default suite because original game data is not shipped
|
||||
in Git. It writes local results to `.reference/`, which is also ignored.
|
||||
|
||||
## Recorded results
|
||||
|
||||
Local validation on 2026-10-10 produced:
|
||||
|
||||
| Difficulty | Boss spawned | First visible alive | Boss killed / reward paid | Complete | Shield remaining | Final credits | Confirmed kills | Positive-health retirements |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 1 | 4,441 | 4,487 | 4,583 | 4,644 | 80 | 42,775 | 118 | 12 |
|
||||
| 2 | 4,441 | 4,487 | 4,703 | 4,764 | 65 | 59,025 | 161 | 37 |
|
||||
|
||||
Step counts use the original 70/3 Hz simulation. Both runs visit all 299
|
||||
cataloged spawn entries; the original difficulty flags decide which entries
|
||||
actually spawn. The boss is `base:enemy_0_28`, with an original reward of 3,000
|
||||
credits. Its last observed health before removal was 0 on difficulty 1 and
|
||||
−3 on difficulty 2. The test asserts that its death pays the reward, that every
|
||||
eligible boss enters the playfield alive and is killed, and that the surviving
|
||||
player reaches `Outcome::Complete`.
|
||||
|
||||
The healthy departure cue plays at timer 40 (steps 4,603 and 4,723), pauses
|
||||
ordinary controls, and starts the forced flight offscreen. At timer 24
|
||||
(steps 4,619 and 4,739), the surviving ship remains drawable. It ends the
|
||||
departure at logical y = −160 before completing. The test checks these
|
||||
milestones and records every countdown step; it does not substitute a direct
|
||||
victory command or terminate immediately after the boss kill.
|
||||
|
||||
A confirmed kill requires an entity to be observed with health at or below zero
|
||||
before removal. An entity removed after a positive-health observation is kept
|
||||
separately and never counted as a confirmed kill. That count is conservative
|
||||
around a possible same-step collision death; it is not an all-enemies kill
|
||||
claim. Mission completion alone cannot satisfy the boss-kill assertion.
|
||||
|
||||
The local catalog's `INDEX` SHA-256 was
|
||||
`4ce78e5e638b6603114dd233ceaa41f386c0963ba4aa02ecf88ce0215b096681`.
|
||||
|
||||
## Controller and assistance limits
|
||||
|
||||
The controller supplies only normal keyboard direction values (`dx`, `dy`
|
||||
each −1, 0 or 1) and a held fire button. Production acceleration, speed limits,
|
||||
banking, shot cooldowns, collisions, incoming damage, rewards and RNG all run
|
||||
normally. The actual playthrough never uses absolute positioning, scripted
|
||||
commands, bombs, shield refills, inventory grants or direct gameplay-state
|
||||
edits after `World::new`.
|
||||
|
||||
For target selection, it leads visible enemies using their current observed
|
||||
motion and gives the boss priority. To choose movement, it evaluates nine
|
||||
direction pairs in disposable cloned worlds over 12 normal simulation steps:
|
||||
three steps holding that direction and nine steps releasing movement. It
|
||||
penalizes predicted damage, then balances aiming, lower-screen positioning and
|
||||
distance from hostile shots. Cloned predictions are discarded; they cannot
|
||||
change the live world's state or RNG.
|
||||
|
||||
This is an automated viability proof with full simulation-state knowledge and
|
||||
precise timing. It demonstrates that the implemented first mission is winnable
|
||||
with ordinary movement and starter equipment. It does not measure human
|
||||
difficulty, input-device usability, graphics quality, or audio accuracy.
|
||||
|
||||
## Replay artifacts and reproducibility
|
||||
|
||||
For each difficulty, the test generates:
|
||||
|
||||
- `.reference/first-mission-fresh-difficulty-N.dem`: the original 12-byte demo
|
||||
record layout, with a sector/wave header, button bytes, observed player
|
||||
position and bank. It includes the complete boss encounter and ends at the
|
||||
flyby cue, before stats-stage forced departure movement: 4,603 and 4,723
|
||||
frames respectively. The first record is the first completed simulation step.
|
||||
- `...-controls.json`: the actual keyboard direction and fire inputs per step.
|
||||
- `...-report.json`: starting pilot, boss observations and deaths, rewards,
|
||||
conservative retirement classification, shield changes and periodic states.
|
||||
- `....jsonl`: every production reference snapshot, including tick, shared RNG,
|
||||
player state, ordered enemies and ordered projectiles.
|
||||
|
||||
The test independently decodes and replays both recordings from an identical
|
||||
fresh pilot. The full keyboard log must reproduce every reference snapshot
|
||||
through `Outcome::Complete`: all 4,644 and 4,764 steps. Legacy demo playback
|
||||
uses recorded absolute positions and banking, and must reproduce every
|
||||
snapshot through the defeated boss and flyby cue: 4,603 and 4,723 steps.
|
||||
The winning controller run itself uses keyboard movement.
|
||||
|
||||
The retained C++ engine records demos before its stats-stage automatic
|
||||
departure movement and skips that movement during playback. End-of-step
|
||||
coordinates therefore stop being equivalent after the flyby cue. A full
|
||||
end-of-step demo showed its first divergence at step 4,605 in player projectiles
|
||||
and RNG. Keeping the legacy-format recording to the verified prefix preserves
|
||||
that record-phase distinction; the full keyboard replay verifies departure.
|
||||
|
||||
The recorded demo SHA-256 values are:
|
||||
|
||||
| Difficulty | SHA-256 |
|
||||
| --- | --- |
|
||||
| 1 | `c5385b30c600f91dab9c0f5009a24204f114626b7f655d18687799e834da34ef` |
|
||||
| 2 | `8d245a537812535ebf0e8e66594a73afef6ff01a14581b80332b095d971cda63` |
|
||||
|
||||
These boss-encounter recordings exceed the retained C++ engine's 2,800-frame
|
||||
static demo buffer. A checked reference-input loader with a larger buffer is
|
||||
needed before loading them there. It must also preserve the fresh pilot and
|
||||
difficulty, rather than substituting `DEMO_MakePlayer`'s showcase loadout.
|
||||
The new recordings have **not** yet been compared against a C++ first-mission
|
||||
boss victory. The existing 1,889-step `DEMO1G1_REC` parity test covers
|
||||
a different original demo and remains a separate acceptance check.
|
||||
+31
-2
@@ -28,8 +28,10 @@ repair kits, and the command ship arrives at step 720. Destroy at least two
|
||||
turrets and the command ship to finish. The boss stays in the playfield until
|
||||
killed; waiting out the mission duration cannot produce victory.
|
||||
|
||||
The mission grants a trial Pulse Cannon, which fires alongside forward guns,
|
||||
and three field repairs. Cycle equipment to **Iron Coast Field Repair** and
|
||||
When inventory space allows, the mission grants a trial Pulse Cannon, which
|
||||
fires alongside forward guns, and three field repairs. Granted equipment
|
||||
remains with the pilot. A full inventory can still start; new grants are
|
||||
skipped while existing equipment is preserved. Cycle equipment to **Iron Coast Field Repair** and
|
||||
press the special button to restore 25 shield per repair. Depots drop extra
|
||||
repairs. Victory pays 5,000 credits plus ordinary enemy rewards. The central
|
||||
turrets and boss patrol overlap the starter-gun firing corridor; the mission
|
||||
@@ -45,6 +47,7 @@ Script objectives are namespaced and saved with the pilot:
|
||||
| `iron_coast_boss:defeated` | Command ships destroyed |
|
||||
| `iron_coast_mission_1:depots_opened` | Supply depots destroyed |
|
||||
| `iron_coast_mission_1:complete` | All required targets destroyed |
|
||||
| `iron_coast_mission_1:trial_issued` | Trial weapon was granted or replenished |
|
||||
|
||||
## Art and audio
|
||||
|
||||
@@ -99,6 +102,32 @@ and the trial Pulse Cannon. These tests use no original images and replace the
|
||||
generated assets with synthetic fixtures. Local package validation additionally checks
|
||||
the actual generated SD/HD images and WAV files.
|
||||
|
||||
Release acceptance passes all six tests in **1.43 seconds**. Synthetic
|
||||
seven-package activation takes 0.179–0.290 seconds including fixture creation
|
||||
and PNG/WAV validation. Actual seven-package validation, including the generated
|
||||
SD/HD artwork and WAV files, passes in **2.234 seconds** with zero overrides.
|
||||
The two playable runs complete as follows:
|
||||
|
||||
| Loadout | Completion step | Final shield | Credits |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Starter forward guns | 882 | 94 | 7,715 |
|
||||
| Trial Pulse Cannon plus forward guns | 786 | 92 | 7,715 |
|
||||
|
||||
Both start at difficulty 2 with shield 100. The test holds fire, fixes the
|
||||
player at (144,160), selects field repairs on step 2 and presses the special
|
||||
button every 90 steps starting at step 61. The starter run removes the granted
|
||||
trial cannon after initial setup. Granted/dropped repairs heal through normal
|
||||
item callbacks; these runs use no direct shield refills, invulnerability,
|
||||
enemy damage commands or victory-objective mutations. They establish that
|
||||
the actual waves and boss can be completed under this controlled input, not
|
||||
that every normal-player route or loadout has been balanced.
|
||||
|
||||
The default debug suite took 34.15 seconds for the earlier five-test version;
|
||||
the release result shows that its slowdown comes mainly from unoptimized
|
||||
simulation, serialization and scripting. PNG color memoization reduced the
|
||||
previous multi-minute asset validation overhead. Release measurements above
|
||||
exclude compilation.
|
||||
|
||||
Visual approval remains provisional: sprite and terrain previews were
|
||||
inspected for readability, canvas alignment and transparency, with numeric
|
||||
palette and repeat-seam checks. Wider player testing should assess difficulty,
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ cargo run --no-default-features -- --data data --mods examples/mods \
|
||||
--enabled aurora --headless aurora:field_trials --ticks 700
|
||||
```
|
||||
|
||||
For the in-game manager, copy `examples/mods/aurora` into the `mods/` directory under `$XDG_DATA_HOME/RaptorReplica` (default `~/.local/share/RaptorReplica/mods/`), or start the Rust engine with `--mods examples/mods`. Open **Mods**, toggle Aurora, then select **Apply selections**. Select its mission from the mission menu. Mods alter the active content set; existing pilots retain the exact mod versions and hashes required by their saves. Use a separate new pilot when trying a different set of mods.
|
||||
For the in-game manager, copy `examples/mods/aurora` into the `mods/` directory under `$XDG_DATA_HOME/RaptorReplica` (default `~/.local/share/RaptorReplica/mods/`), or start the Rust engine with `--mods examples/mods`. Open **Mods**, toggle Aurora, then select **Apply selections**. Select its mission from the mission menu. Existing pilots can add mods while preserving required versions, hashes and relative load order; the next save records the expanded active set. Disabling, changing or reordering required mods blocks loading with details. Use another pilot for an incompatible set.
|
||||
|
||||
In the Mods screen, Enter toggles a package or activates the selected action. Rescan after editing a package on disk. Load dependencies before dependents; independent packages retain your chosen priority. Later explicit overrides win. Changes made through the pause menu remain pending until the main menu. Candidate registries and scripts are validated before the active set changes.
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Original mission ending and death
|
||||
|
||||
The Rust simulation now follows the retained engine's `RAP_ThinkStats` ending
|
||||
sequence for missions with `legacy_map`. Authored mod missions retain their
|
||||
immediate completion/failure contract. This is a source-based port with portable
|
||||
synthetic regressions; a C++ recording through a complete ending or death is
|
||||
still required before claiming transition parity.
|
||||
|
||||
## Departure
|
||||
|
||||
Removing the final eligible original enemy arms a 60-step countdown before
|
||||
hostile-shot processing, matching `ENEMY_Remove`. Player damage is suppressed
|
||||
while the countdown is active, matching `OBJS_SubEnergy`. At the start of each
|
||||
subsequent step the countdown decrements after input and before world updates.
|
||||
An entry value of zero still executes that final simulation step before the
|
||||
outcome changes.
|
||||
|
||||
At 40, control pauses and `FX_FLYBY` is emitted. Below 40, the ship moves toward
|
||||
the horizontal corridor 152–168 in eight-pixel increments and upward four
|
||||
pixels. The original paused input phase retains the prior forced delta; the
|
||||
statistics phase then applies the new delta. Consequently most departure steps
|
||||
contain both moves. Boundary clamping is suspended during the ending. A healthy
|
||||
ship remains drawable as it leaves the screen; `Outcome::Complete` follows the
|
||||
final simulation pass, rather than appearing while the ship is stationary.
|
||||
|
||||
`World.control_paused`, `departure_delta`, `end_timer` and `draw_player` expose
|
||||
these states to the SDL-independent renderer/input consumer. The synthetic
|
||||
stationary-pilot fixture, starting with an already armed countdown, completes
|
||||
after 61 steps at `(160, -160)`.
|
||||
|
||||
Original demo playback assigns recorded coordinates and banking directly and
|
||||
bypasses both automatic moves, matching `IPT_MovePlayer`'s playback early return.
|
||||
Coordinates captured at the end of a newly authored step do not necessarily
|
||||
represent the original recording phase; use deterministic keyboard-input replay
|
||||
for strict reproduction of an entire newly recorded ending.
|
||||
|
||||
## Death
|
||||
|
||||
Zero energy starts the same countdown instead of immediately failing the
|
||||
mission. Each statistics pass requests a medium-air and small-air explosion
|
||||
with randomized coordinates. Above 24 it also emits a randomly panned air
|
||||
explosion sound; the original randomized sound pitch consumes the shared RNG.
|
||||
|
||||
At 24, `draw_player` becomes false. The sequence requests its centered explosion,
|
||||
three explosion sounds and 512 alternating large-air/air-blast effects. The
|
||||
original animation pool holds at most 100 effects. All 1,024 burst-coordinate
|
||||
RNG calls still occur when that pool fills. The portable fixture verifies the
|
||||
first randomized positions, sound pan choices, the pool limit and exact RNG
|
||||
advance at the burst, including the separate palette-update RNG call. A pilot
|
||||
whose energy is initially zero remains `Playing` through the visible explosion
|
||||
sequence and reaches `Failed` after 62 steps.
|
||||
|
||||
Low-energy equipment loss runs after the death/departure statistics, preserving
|
||||
the existing shared RNG and automatic weapon-selection behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
Run the portable regressions without original assets:
|
||||
|
||||
```sh
|
||||
cargo test --no-default-features --test transitions --test gameplay
|
||||
```
|
||||
|
||||
The transition suite has five tests covering successful departure, direct demo
|
||||
positions, immunity beginning on final-enemy removal, delayed death with the
|
||||
burst RNG/pool cap, and authored mod outcome compatibility. The gameplay suite
|
||||
also retains beam ordering, raw demo bounds, equipment loss, kamikaze movement,
|
||||
Energy Grab, Ground Bomb and Mega Bomb checks.
|
||||
|
||||
After this change, the complete available `DEMO1G1_REC` still matched the rebuilt
|
||||
C++ engine's gameplay snapshots across all 1,889 simulation steps. That recording
|
||||
ends before its boss, so it proves the transition changes did not disturb the
|
||||
captured pre-ending behavior; it does not validate the new transition itself.
|
||||
See [campaign-validation.md](campaign-validation.md) for reference rebuild and
|
||||
comparison commands, captured fields, original-data checks and limitations.
|
||||
|
||||
The relevant retained sources are `raptor/src/rap.cpp` (`RAP_ThinkStats` and
|
||||
`Do_Game`), `raptor/src/rap.h` (`END_DURATION`, `END_EXPLODE`, `END_FLYOFF`),
|
||||
`raptor/src/input.cpp` (`IPT_MovePlayer`, `IPT_FMovePlayer`),
|
||||
`raptor/src/enemy.cpp` (`ENEMY_Remove`) and `raptor/src/objects.cpp`
|
||||
(`OBJS_SubEnergy`). God-mode/debug behavior, rumble, warning-light presentation,
|
||||
cinematic sequencing and a native transition reference are outside this scoped
|
||||
verification.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Sprite and terrain validation
|
||||
|
||||
`tests/visuals.rs` separates portable synthetic checks from local checks of the original catalog. Generated contact sheets and original pixels stay under ignored `.reference/visuals/`; they are not shipped with the engine or committed.
|
||||
|
||||
The independent test oracle follows the unoptimized corner rules in the [published Scale2x/Scale3x algorithm specification](https://www.scale2x.it/algorithm), with its clamped border sampling. Scale4x applies the independent Scale2x oracle twice. The renderer uses a shared optimized guard, so the oracle and renderer express the rules differently.
|
||||
|
||||
The portable suite checks all 19,683 neighborhoods over two palette colors plus transparency, additional multi-color patterns and one-pixel borders. It verifies exact output indices, opaque palette indices 0/255, transparent composition, palette changes without changing stored indices, clipping on every viewport edge, indexed shadow masks, sharply scaled bitmap text and neighbor context at terrain boundaries. Its independent-tile fixture deliberately produces different results when tiles are filtered separately, so the seam check cannot pass trivially. Shadow checks cover six-bit RGB clamping, the original Manhattan palette-distance and last-equal tie rules, native ground-mask offsets, signed air projection, collapsed-row suppression, sparse spans, clipping at 1×..4× and interpolated translation without coverage loss.
|
||||
|
||||
Run portable checks with:
|
||||
|
||||
```sh
|
||||
cargo test --test visuals
|
||||
```
|
||||
|
||||
Run the original-data checks and create private contact sheets with:
|
||||
|
||||
```sh
|
||||
cargo test --test visuals original_sequences_preserve_canvases_palette_origins_and_terrain_context -- --ignored
|
||||
cargo test --test visuals original_shadow_tables_and_banking_masks_match_indexed_projection -- --ignored
|
||||
```
|
||||
|
||||
The local checks cover all seven light, dark and firing-flash player banking frames; all 42 `GEXPLO_BLK` ground explosion frames; all 16 `AIRBOOM_PIC` and 13 `EXPLO2_BLK` frames; four micro-missile and gun-ejecta frames; six `SHIP22G1_PIC` boss frames and four `SHIP08G1_PIC` enemy frames. Each original frame is compared against the oracle at 1×, 2×, 3× and 4×, with exact logical canvas dimensions and original palette labels retained. The report records frame IDs, logical canvases, full-canvas center anchors and visible bounds at every scale.
|
||||
|
||||
The original 20-byte GFX header contains type, two option fields, width and height; its option fields are not replacement x/y origins. Span x/y coordinates locate pixels within the unchanged canvas. Player rendering uses that top-left canvas origin. Original centered effects use half the full canvas width/height, as recorded by `ANIMS_Register` in `raptor/src/anims.cpp`; visible explosion bounds must not become animation anchors. Missing transparent margins or auto-cropping frames would move those anchors.
|
||||
|
||||
A three-by-three patch from the first original mission is composed at native resolution and filtered as one image. The center tile filtered with two original pixels of neighboring context must exactly match the center of the full joined patch at 2×, 3× and 4×. The renderer's two-pixel terrain gutter covers Scale4x's two successive passes. This establishes filter consistency across the tested boundaries; it does not assess whether different source tiles were artistically seamless.
|
||||
|
||||
Each sequence emits `<NAME>-nearest4.png`, `<NAME>-scale4.png` and `<NAME>-runtime4.png`. The runtime sheet applies the engine's per-asset fallback. Read left-to-right, then top-to-bottom, up to seven frames per row. Terrain comparisons are `terrain-joined-2x.png`, `terrain-joined-3x.png`, `terrain-joined-4x.png` and matching `terrain-independent-*` files. `report.json` stores frame order, bounds, opaque pixel counts, fallback flags, selected terrain row and the number of pixels changed by independent tile filtering.
|
||||
|
||||
## Review findings
|
||||
|
||||
On 2026-10-10, all fourteen portable tests and both local catalog checks passed. The sequence check covered all 110 frames at every scale plus the terrain patch. Complete nearest-neighbor and Scale4 contact sheets were inspected for banking, explosions, projectiles, the sample enemy and the opening boss animation. No canvas shift, clipped replacement canvas, lost palette identity or changed simulation dimensions was found. Banking retains the nose and engine registration across all seven poses. Large explosion lobes and expanding rings remain aligned throughout their sequences.
|
||||
|
||||
Two nearest-neighbor fallback groups are implemented in `render::prefers_nearest`, covering 26 original frames:
|
||||
|
||||
- `NMSHOT_BLK` frames 0..3, assets `0x101c3..0x101c6`: each frame has four visible source pixels. At 4×, ScaleNx keeps 44 opaque pixels against nearest-neighbor's 64, reducing visible coverage by 31.25% and changing the small square mark into a round bead. The implemented nearest-neighbor fallback preserves this tiny projectile's brightness and shape.
|
||||
- Sparse late `GEXPLO_BLK` frames 20..41, assets `0x10178..0x1018d`: Scale4 reduces opaque coverage by approximately 10..25%. The implemented nearest-neighbor fallback preserves those residual embers; the large early flame frames retain the smoother filter. The initial 17-pixel flash also loses about 10.3% coverage and remains a candidate for the tiny-effect fallback after playback review.
|
||||
|
||||
The original-data test exercises the actual `SpriteCache` fallback route at all four factors and asserts exact nearest-neighbor pixels and full original opaque coverage for every fallback frame. Other frames must still match ScaleNx. Supplied HD PNGs bypass this original-asset filtering policy and retain their detail.
|
||||
|
||||
The desktop shadow pass follows `shadow.cpp` and `gfxapi.cpp`: ground masks use the -9 palette table at (-3,+4); aircraft and player masks use a separate -6 table at (-10,+20), projected through view (160,100,1000), depth 1280 and distance 200. The renderer retains signed integer division followed by arithmetic shift, inclusive horizontal span endpoints, and first-row coverage when several source rows collapse to one projected row. It shades existing palette indices rather than painting flat translucent black. Ground shadows precede ground enemies; air and player shadows follow ground enemies and precede ground effects and aircraft. Legacy enemy field 11 controls presence, with the original 25-ground/50-air limits. Mod enemies receive the corresponding ground/air shadow from their supplied SD logical canvas. Optional HD artwork retains this logical shadow mask.
|
||||
|
||||
The original shadow code's preliminary bounds check projects its origin twice and calculates height using the projected x coordinate. Rust clips each actual projected span instead, correcting missing valid shadows near the lower edge. This is a presentation culling repair; original span projection and palette mapping are retained. It does not alter collisions or simulation state.
|
||||
|
||||
The local shadow test compares all 256 entries of the original -9, -6 and +9 tables against an independent distance/tie oracle. It verifies all seven player banking masks against an independent native span raster at all four factors and emits `player-shadow-banking-4x.png`, `ground-shadow-4x.png` and `shadow-tables.json` under the private visual directory. The first contact sheet pairs each complete banking pose with its projected shadow on original terrain; the second checks native ground offset and registration.
|
||||
|
||||
Both shadow contacts were inspected after the tests passed. The seven banking shadows retain their changing silhouette and full-canvas registration without transparent rectangles or missing lower-screen spans. The ground defense shadow retains the native (-3,+4) offset. Shadow pixels remain terrain palette colors, so palette changes and indexed composition remain available.
|
||||
|
||||
The inspected light ship frames retain 97.2..98.3% of nearest-neighbor opaque coverage at 4×, dark/flash frames retain at least 97.8%, the large boss retains at least 99.6% and the sample enemy retains 99.9%. These are presentation measurements, not collision masks. Their full original logical canvases remain unchanged. No nearest-neighbor fallback is recommended for those reviewed sequences.
|
||||
|
||||
The original terrain patch exposed 113 differing pixels at 2×, 297 at 3× and 727 at 4× when the tiles were filtered independently. Filtering the joined patch with the tested two-pixel neighbor gutter exactly matched the full-image center at each factor. The paired contact sheets show subtle texture discontinuities along independent tile boundaries; joined filtering retains the diagonal water transition. This review covers the crisp original terrain path; supplied HD terrain needs its own artistic edge review.
|
||||
|
||||
The optional HD player proof remains a separate review: it replaces only straight flight, so banking still changes visual style. The generated extreme-bank attempt remains excluded because it shortened the engines and failed the footprint check; crisp original fallback is retained for that frame.
|
||||
|
||||
The frontend previously requested `AIRMED_BLK` for effects, but that name does not exist in the original catalog. Actual original sequences include `AIRBOOM_PIC`, `GEXPLO_BLK`, `EXPLO2_BLK`, flak and sparks. This finding was reported to the engine owner. A generic explosion sequence repairs the visible fallback, but full effect identity, duration, layer, origin and update parity require separate comparison with the C++ animation system.
|
||||
|
||||
The new enemy packages include their generated SD/HD artwork and audio in Git. Their original style-reference pixels under `art/source/` remain excluded. Their per-package `art-qa.json` and `provenance.json` provide a separate record of supplied-art canvas alignment and generation prompts.
|
||||
@@ -7,9 +7,12 @@ Choose **Iron Coast 1 — Breakwater** in the mission list.
|
||||
|
||||
Disable at least two harbor turrets and destroy the command ship to complete
|
||||
the raid. Scout waves arrive during the approach; two supply depots release
|
||||
repair kits. The mission grants a trial Pulse Cannon and three field repairs.
|
||||
repair kits. When inventory space allows, the mission grants a trial Pulse
|
||||
Cannon and three field repairs. Granted equipment remains with the pilot.
|
||||
Repairs restore 25 shield when selected and used with the special button.
|
||||
Victory awards 5,000 credits in addition to ordinary destruction rewards.
|
||||
A full inventory can still start the mission; existing equipment is preserved
|
||||
and new grants are skipped when there is no free slot.
|
||||
|
||||
This mission uses explicit script completion. Its duration is a catalog hint;
|
||||
waiting out the timer cannot win. The boss stays in the firing corridor, and
|
||||
@@ -27,3 +30,12 @@ data/misc/PALETTE_DAT` to regenerate the exact
|
||||
packaged crops, SD palette conversion, HD footprints and synthesized WAVs.
|
||||
See `prompts.md`, `asset-report.json`, and `docs/iron-coast.md` for provenance
|
||||
and validation limits.
|
||||
|
||||
The release acceptance run completes with starter guns at step **882**
|
||||
(shield 94) and the trial Pulse Cannon at step **786** (shield 92), both with
|
||||
7,715 credits. These are deterministic headless runs on difficulty 2: initial
|
||||
shield 100, held fire, position fixed at (144,160), repairs selected on step 2
|
||||
and the special button pressed every 90 steps starting at step 61. They use
|
||||
the mission's granted/dropped repairs; they apply no direct shield refills or
|
||||
enemy damage commands. Human-play difficulty and audiovisual approval remain
|
||||
subject to normal play testing.
|
||||
@@ -1,12 +1,24 @@
|
||||
fn on_mission_start(event, state, random) {
|
||||
#{commands:[
|
||||
#{op:"give_item", item:"pulse_cannon:equipment", count:1},
|
||||
#{op:"give_item", item:"iron_coast_mission_1:repair", count:3},
|
||||
let commands=[
|
||||
#{op:"objective", key:"iron_coast_mission_1:phase", value:1},
|
||||
#{op:"objective", key:"iron_coast_mission_1:turrets_required", value:2},
|
||||
#{op:"objective", key:"iron_coast_mission_1:complete", value:0},
|
||||
#{op:"play_sound", sound:"iron_coast_mission_1:approach"}
|
||||
],state:#{dock_alarm:false,boss_alarm:false,complete:false}}
|
||||
];
|
||||
// A full veteran inventory must not prevent a mission from starting.
|
||||
// Existing equipment may receive its normal refill without adding a slot.
|
||||
let slots=event.player.inventory.len();
|
||||
if event.player.inventory.contains("iron_coast_mission_1:repair") || slots<20 {
|
||||
commands.push(#{op:"give_item",item:"iron_coast_mission_1:repair",count:3});
|
||||
if !event.player.inventory.contains("iron_coast_mission_1:repair") {slots+=1;}
|
||||
}
|
||||
let trial=0;
|
||||
if event.player.inventory.contains("pulse_cannon:equipment") || slots<20 {
|
||||
commands.push(#{op:"give_item",item:"pulse_cannon:equipment",count:1});
|
||||
trial=1;
|
||||
}
|
||||
commands.push(#{op:"objective",key:"iron_coast_mission_1:trial_issued",value:trial});
|
||||
#{commands:commands,state:#{dock_alarm:false,boss_alarm:false,complete:false}}
|
||||
}
|
||||
|
||||
fn on_tick(event, state, random) {
|
||||
|
||||
+260
-94
@@ -7,7 +7,10 @@ use crate::{
|
||||
hd::HdAssets,
|
||||
modding::{self, ActiveMods, Discovery},
|
||||
persistence::{Config, Paths, Pilot},
|
||||
render::{BitmapFont, Canvas, SpriteCache, nearest, upscale},
|
||||
render::{
|
||||
BitmapFont, Canvas, SpriteCache, legacy_light_table, nearest, prefers_nearest,
|
||||
shade_air_shadow, shade_ground_shadow, upscale,
|
||||
},
|
||||
simulation::{Clock, Input, Outcome, World},
|
||||
};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
@@ -79,6 +82,8 @@ struct Host {
|
||||
raw: BTreeMap<u32, Sprite>,
|
||||
mod_art: BTreeMap<String, Sprite>,
|
||||
palette: [[u8; 3]; 256],
|
||||
shadow_table: [u8; 256],
|
||||
air_shadow_table: [u8; 256],
|
||||
audio: Option<AudioDevice<Mixer>>,
|
||||
mod_audio: BTreeMap<ContentId, DigitalSample>,
|
||||
desired_music: Option<u32>,
|
||||
@@ -133,6 +138,9 @@ impl Host {
|
||||
restore_scripts(&mut active.scripts, &pilot);
|
||||
let hd = HdAssets::load(&catalog.root.join("hd"), &catalog);
|
||||
let palette = catalog.palette()?;
|
||||
let vga_palette = palette.map(|color| color.map(|component| component >> 2));
|
||||
let shadow_table = legacy_light_table(&vga_palette, -9);
|
||||
let air_shadow_table = legacy_light_table(&vga_palette, -6);
|
||||
let font = BitmapFont::decode(&catalog.named("SYSTEM_FNT")?)?;
|
||||
let cache = SpriteCache::new(Some(paths.cache.join("sprites")));
|
||||
let pending = config.enabled_mods.clone();
|
||||
@@ -165,6 +173,8 @@ impl Host {
|
||||
raw: BTreeMap::new(),
|
||||
mod_art: BTreeMap::new(),
|
||||
palette,
|
||||
shadow_table,
|
||||
air_shadow_table,
|
||||
audio: None,
|
||||
mod_audio: BTreeMap::new(),
|
||||
desired_music: None,
|
||||
@@ -190,6 +200,7 @@ impl Host {
|
||||
self.pilot.script_rng = self.active.scripts.rng.clone();
|
||||
self.pilot
|
||||
.validate(&self.active.registry, &self.active.pins)?;
|
||||
self.pilot.mods = self.active.pins.clone();
|
||||
self.pilot.save(&self.paths.save_path(self.slot))?;
|
||||
self.save_config()
|
||||
}
|
||||
@@ -903,7 +914,11 @@ impl Host {
|
||||
let scaled = if sprite.width == art.logical_width as usize
|
||||
&& sprite.height == art.logical_height as usize
|
||||
{
|
||||
self.cache.get(&sprite, canvas.scale, art.nearest)?.clone()
|
||||
let pixelated = art.nearest
|
||||
|| art
|
||||
.asset
|
||||
.is_some_and(|id| prefers_nearest(id + frame % art.frames));
|
||||
self.cache.get(&sprite, canvas.scale, pixelated)?.clone()
|
||||
} else {
|
||||
let mut out = Sprite::blank(
|
||||
art.logical_width as usize * canvas.scale,
|
||||
@@ -938,6 +953,143 @@ impl Host {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn shadow(
|
||||
&mut self,
|
||||
canvas: &mut Canvas,
|
||||
art: &Art,
|
||||
owner: &str,
|
||||
frame: u32,
|
||||
x: f32,
|
||||
y: f32,
|
||||
ground: bool,
|
||||
) -> Result<()> {
|
||||
// Original masks retain their native canvas even when HD art is enabled.
|
||||
// Mod masks use the supplied SD artwork and explicit logical dimensions.
|
||||
let source = if let Some(asset) = art.asset {
|
||||
self.raw(asset + frame % art.frames)?
|
||||
} else {
|
||||
art_sprite_variant(
|
||||
&self.catalog,
|
||||
&self.active.registry,
|
||||
owner,
|
||||
art,
|
||||
frame,
|
||||
false,
|
||||
)?
|
||||
};
|
||||
let mask = if (source.width, source.height)
|
||||
== (art.logical_width as usize, art.logical_height as usize)
|
||||
{
|
||||
source
|
||||
} else {
|
||||
let mut logical =
|
||||
Sprite::blank(art.logical_width as usize, art.logical_height as usize);
|
||||
for row in 0..logical.height {
|
||||
for column in 0..logical.width {
|
||||
logical.pixels[row * logical.width + column] =
|
||||
source.pixels[(row * source.height / logical.height) * source.width
|
||||
+ column * source.width / logical.width];
|
||||
}
|
||||
}
|
||||
logical
|
||||
};
|
||||
if ground {
|
||||
shade_ground_shadow(canvas, &mask, x, y, &self.shadow_table);
|
||||
} else {
|
||||
shade_air_shadow(canvas, &mask, x, y, &self.air_shadow_table);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn projectiles(
|
||||
&mut self,
|
||||
canvas: &mut Canvas,
|
||||
world: &World,
|
||||
hostile: bool,
|
||||
alpha: f32,
|
||||
) -> Result<()> {
|
||||
for p in world.projectiles.iter().filter(|p| p.hostile == hostile) {
|
||||
let def = self.active.registry.weapons[&p.weapon].clone();
|
||||
let x = p.px as f32 + (p.x - p.px) as f32 * alpha;
|
||||
let y = p.py as f32 + (p.y - p.py) as f32 * alpha;
|
||||
if p.legacy.is_some() || p.legacy_enemy.is_some() {
|
||||
if let Some(asset) = World::projectile_asset(p, &self.active.registry) {
|
||||
let frame = if let Some(shot) = &p.legacy_enemy {
|
||||
shot.art_frame % shot.frames.max(1)
|
||||
} else {
|
||||
p.legacy.as_ref().map_or(0, |shot| shot.art_frame)
|
||||
};
|
||||
let original = self.raw(asset + frame)?;
|
||||
let hd = if self.config.hd_mode {
|
||||
self.hd.sprite(asset + frame, canvas.scale, &self.palette)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sprite = match hd {
|
||||
Some(sprite) => sprite,
|
||||
None => self
|
||||
.cache
|
||||
.get(&original, canvas.scale, prefers_nearest(asset + frame))?
|
||||
.clone(),
|
||||
};
|
||||
canvas.blit(
|
||||
&sprite,
|
||||
(x * canvas.scale as f32).round() as i32,
|
||||
(y * canvas.scale as f32).round() as i32,
|
||||
);
|
||||
}
|
||||
} else if matches!(def.behavior, crate::content::WeaponBehavior::Beam) {
|
||||
canvas.rect(x as i32, 0, 2, world.player.y, 255);
|
||||
} else if let Some(art) = def.art {
|
||||
let owner = self.active.registry.owners[&p.weapon].clone();
|
||||
self.art(
|
||||
canvas,
|
||||
&art,
|
||||
&owner,
|
||||
p.age / 2,
|
||||
x - art.logical_width as f32 / 2.0,
|
||||
y - art.logical_height as f32 / 2.0,
|
||||
)?;
|
||||
} else {
|
||||
canvas.rect(x as i32, y as i32, 2, 4, if p.hostile { 34 } else { 255 });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn effects(
|
||||
&mut self,
|
||||
canvas: &mut Canvas,
|
||||
world: &World,
|
||||
ground: bool,
|
||||
alpha: f32,
|
||||
) -> Result<()> {
|
||||
for effect in world.effects.iter().filter(|e| e.ground == ground) {
|
||||
let (asset, frames) = effect.kind.artwork();
|
||||
let frame = effect.age.saturating_sub(1).min(frames - 1);
|
||||
let original = self.raw(asset + frame)?;
|
||||
let hd = if self.config.hd_mode {
|
||||
self.hd.sprite(asset + frame, canvas.scale, &self.palette)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sprite = match hd {
|
||||
Some(sprite) => sprite,
|
||||
None => self
|
||||
.cache
|
||||
.get(&original, canvas.scale, prefers_nearest(asset + frame))?
|
||||
.clone(),
|
||||
};
|
||||
let x = effect.px as f32 + (effect.x - effect.px) as f32 * alpha
|
||||
- original.width as f32 / 2.0;
|
||||
let y = effect.py as f32 + (effect.y - effect.py) as f32 * alpha
|
||||
- original.height as f32 / 2.0;
|
||||
canvas.blit(
|
||||
&sprite,
|
||||
(x * canvas.scale as f32).round() as i32,
|
||||
(y * canvas.scale as f32).round() as i32,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn scene(&mut self, canvas: &mut Canvas) -> Result<()> {
|
||||
let Some(world) = self.world.clone() else {
|
||||
return Ok(());
|
||||
@@ -1039,7 +1191,66 @@ impl Host {
|
||||
}
|
||||
}
|
||||
}
|
||||
let dark = matches!(
|
||||
world.mission.0.as_str(),
|
||||
"base:sector_2_wave_8" | "base:sector_3_wave_3"
|
||||
);
|
||||
let player = self.catalog.id(if dark {
|
||||
if world.player_flash {
|
||||
"FPLAYER_PIC"
|
||||
} else {
|
||||
"DPLAYER_PIC"
|
||||
}
|
||||
} else {
|
||||
"LPLAYER_PIC"
|
||||
})?;
|
||||
let player_frame = player + world.player.bank.clamp(0, 6) as u32;
|
||||
for ground in [true, false] {
|
||||
let mut shadows = 0;
|
||||
for e in &world.enemies {
|
||||
let def = self.active.registry.enemies[&e.definition].clone();
|
||||
if def.ground != ground
|
||||
|| def
|
||||
.legacy
|
||||
.as_ref()
|
||||
.is_some_and(|legacy| legacy.fields[11] == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if shadows >= if ground { 25 } else { 50 } {
|
||||
break;
|
||||
}
|
||||
if let Some(art) = def.art {
|
||||
let owner = self.active.registry.owners[&e.definition].clone();
|
||||
self.shadow(
|
||||
canvas,
|
||||
&art,
|
||||
&owner,
|
||||
if def.legacy.is_some() {
|
||||
e.art_frame
|
||||
} else {
|
||||
e.frame
|
||||
},
|
||||
interp(e.x, e.px),
|
||||
interp(e.y, e.py),
|
||||
ground,
|
||||
)?;
|
||||
shadows += 1;
|
||||
}
|
||||
}
|
||||
if !ground && shadows < 50 && world.draw_player {
|
||||
let mask = self.raw(player_frame)?;
|
||||
shade_air_shadow(
|
||||
canvas,
|
||||
&mask,
|
||||
interp(world.player.x, world.player.px),
|
||||
interp(world.player.y, world.player.py),
|
||||
&self.air_shadow_table,
|
||||
);
|
||||
}
|
||||
if !ground {
|
||||
self.effects(canvas, &world, true, alpha)?;
|
||||
}
|
||||
for e in &world.enemies {
|
||||
let def = self.active.registry.enemies[&e.definition].clone();
|
||||
if def.ground != ground {
|
||||
@@ -1062,80 +1273,24 @@ impl Host {
|
||||
}
|
||||
}
|
||||
}
|
||||
for p in &world.projectiles {
|
||||
let def = self.active.registry.weapons[&p.weapon].clone();
|
||||
let x = interp(p.x, p.px);
|
||||
let y = interp(p.y, p.py);
|
||||
if p.legacy.is_some() || p.legacy_enemy.is_some() {
|
||||
if let Some(asset) = World::projectile_asset(p, &self.active.registry) {
|
||||
let frame = if let Some(shot) = &p.legacy_enemy {
|
||||
shot.art_frame % shot.frames.max(1)
|
||||
} else {
|
||||
p.legacy.as_ref().map_or(0, |shot| shot.art_frame)
|
||||
};
|
||||
let original = self.raw(asset + frame)?;
|
||||
let hd = if self.config.hd_mode {
|
||||
self.hd.sprite(asset + frame, canvas.scale, &self.palette)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sprite = match hd {
|
||||
Some(sprite) => sprite,
|
||||
None => self.cache.get(&original, canvas.scale, false)?.clone(),
|
||||
};
|
||||
canvas.blit(
|
||||
&sprite,
|
||||
(x * canvas.scale as f32).round() as i32,
|
||||
(y * canvas.scale as f32).round() as i32,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if matches!(def.behavior, crate::content::WeaponBehavior::Beam) {
|
||||
canvas.rect(x as i32, 0, 2, world.player.y, 255);
|
||||
} else if let Some(art) = def.art {
|
||||
let owner = self.active.registry.owners[&p.weapon].clone();
|
||||
self.art(
|
||||
canvas,
|
||||
&art,
|
||||
&owner,
|
||||
p.age / 2,
|
||||
x - art.logical_width as f32 / 2.0,
|
||||
y - art.logical_height as f32 / 2.0,
|
||||
)?;
|
||||
self.projectiles(canvas, &world, false, alpha)?;
|
||||
if world.draw_player {
|
||||
let s = self.raw(player_frame)?;
|
||||
let hd = if self.config.hd_mode {
|
||||
self.hd.sprite(player_frame, canvas.scale, &self.palette)?
|
||||
} else {
|
||||
canvas.rect(x as i32, y as i32, 2, 4, if p.hostile { 34 } else { 255 });
|
||||
}
|
||||
None
|
||||
};
|
||||
let scaled = match hd {
|
||||
Some(sprite) => sprite,
|
||||
None => self.cache.get(&s, canvas.scale, false)?.clone(),
|
||||
};
|
||||
canvas.blit(
|
||||
&scaled,
|
||||
(interp(world.player.x, world.player.px) * canvas.scale as f32) as i32,
|
||||
(interp(world.player.y, world.player.py) * canvas.scale as f32) as i32,
|
||||
);
|
||||
}
|
||||
let dark = matches!(
|
||||
world.mission.0.as_str(),
|
||||
"base:sector_2_wave_8" | "base:sector_3_wave_3"
|
||||
);
|
||||
let player = self.catalog.id(if dark {
|
||||
if world.player_flash {
|
||||
"FPLAYER_PIC"
|
||||
} else {
|
||||
"DPLAYER_PIC"
|
||||
}
|
||||
} else {
|
||||
"LPLAYER_PIC"
|
||||
})?;
|
||||
let player_frame = player + world.player.bank.clamp(0, 6) as u32;
|
||||
let s = self.raw(player_frame)?;
|
||||
let hd = if self.config.hd_mode {
|
||||
self.hd.sprite(player_frame, canvas.scale, &self.palette)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let scaled = match hd {
|
||||
Some(sprite) => sprite,
|
||||
None => self.cache.get(&s, canvas.scale, false)?.clone(),
|
||||
};
|
||||
canvas.blit(
|
||||
&scaled,
|
||||
(interp(world.player.x, world.player.px) * canvas.scale as f32) as i32,
|
||||
(interp(world.player.y, world.player.py) * canvas.scale as f32) as i32,
|
||||
);
|
||||
for pickup in &world.pickups {
|
||||
if pickup
|
||||
.legacy
|
||||
@@ -1168,26 +1323,8 @@ impl Host {
|
||||
)?;
|
||||
}
|
||||
}
|
||||
for effect in &world.effects {
|
||||
if let Ok(id) = self.catalog.id("AIRMED_BLK") {
|
||||
if let Ok(s) = self.raw(id + effect.age as u32 / 2) {
|
||||
let scaled = self.cache.get(&s, canvas.scale, false)?.clone();
|
||||
canvas.blit(
|
||||
&scaled,
|
||||
(effect.x - s.width as i32 / 2) * canvas.scale as i32,
|
||||
(effect.y - s.height as i32 / 2) * canvas.scale as i32,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
canvas.rect(
|
||||
effect.x - effect.age as i32 / 2,
|
||||
effect.y - 2,
|
||||
effect.age as i32 + 2,
|
||||
4,
|
||||
50,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.effects(canvas, &world, false, alpha)?;
|
||||
self.projectiles(canvas, &world, true, alpha)?;
|
||||
// The original shield bars sit beside the playfield. A full-width
|
||||
// bottom panel would cover the player's engines at its initial y=160.
|
||||
canvas.rect(0, 0, 16, 200, 0);
|
||||
@@ -1463,7 +1600,9 @@ pub fn run(
|
||||
screenshot: Option<PathBuf>,
|
||||
preview: Option<ContentId>,
|
||||
movie_preview: Option<MovieKind>,
|
||||
benchmark: bool,
|
||||
) -> Result<()> {
|
||||
let setup_start = Instant::now();
|
||||
let mut host = Host::new(catalog, base, paths, mods_root, frames.is_some())?;
|
||||
if let Some(id) = preview {
|
||||
if frames.is_some() {
|
||||
@@ -1541,11 +1680,15 @@ pub fn run(
|
||||
host.music("MAINMENU_MUS");
|
||||
}
|
||||
let mut previous = Instant::now();
|
||||
let setup_ms = setup_start.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut work_times = Vec::new();
|
||||
let mut frame_times = Vec::new();
|
||||
let mut count = 0u32;
|
||||
let mut cycle = false;
|
||||
let mut bomb = false;
|
||||
let mut quit = false;
|
||||
while !quit {
|
||||
let frame_start = Instant::now();
|
||||
for event in events.poll_iter() {
|
||||
match event {
|
||||
Event::Quit { .. } => quit = true,
|
||||
@@ -1868,7 +2011,12 @@ pub fn run(
|
||||
image::save_buffer(path, &presented, w, h, image::ColorType::Rgba8)?;
|
||||
}
|
||||
}
|
||||
let work_ms = frame_start.elapsed().as_secs_f64() * 1000.0;
|
||||
canvas.present();
|
||||
if benchmark && count >= 5 && !frames.is_some_and(|n| count + 1 >= n) {
|
||||
work_times.push(work_ms);
|
||||
frame_times.push(frame_start.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
count += 1;
|
||||
if frames.is_some_and(|n| count >= n) {
|
||||
quit = true;
|
||||
@@ -1877,6 +2025,24 @@ pub fn run(
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
if benchmark && !work_times.is_empty() {
|
||||
work_times.sort_by(f64::total_cmp);
|
||||
frame_times.sort_by(f64::total_cmp);
|
||||
let percentile = |values: &[f64], p: usize| values[(values.len() - 1) * p / 100];
|
||||
println!(
|
||||
"benchmark renderer={} size={}x{} scale={} hd={} startup_ms={setup_ms:.2} samples={} work_p50_ms={:.2} work_p95_ms={:.2} frame_p50_ms={:.2} frame_p95_ms={:.2}",
|
||||
canvas.info().name,
|
||||
host.config.width,
|
||||
host.config.height,
|
||||
host.config.scale,
|
||||
host.config.hd_mode,
|
||||
work_times.len(),
|
||||
percentile(&work_times, 50),
|
||||
percentile(&work_times, 95),
|
||||
percentile(&frame_times, 50),
|
||||
percentile(&frame_times, 95)
|
||||
);
|
||||
}
|
||||
if frames.is_none() {
|
||||
host.config.save(&host.paths)?;
|
||||
}
|
||||
|
||||
+7
-1
@@ -20,9 +20,14 @@ fn run() -> Result<()> {
|
||||
.position(|s| s == key)
|
||||
.and_then(|i| args.get(i + 1))
|
||||
};
|
||||
let benchmark = args.iter().any(|a| a == "--benchmark");
|
||||
anyhow::ensure!(
|
||||
!benchmark || arg("--smoke-frames").is_some(),
|
||||
"--benchmark requires --smoke-frames"
|
||||
);
|
||||
if args.iter().any(|a| a == "--help" || a == "-h") {
|
||||
println!(
|
||||
"raptor-rust [--data DIR] [--mods DIR]\n --verify-assets Check catalog and all original mission definitions\n --validate-mods ID,... Validate selected local mod packages\n --schemas DIR Write mod API JSON schemas\n --headless MISSION --ticks N Deterministic simulation smoke run\n --replay ASSET_NAME --trace FILE Record legacy demo simulation states\n --import-legacy FILE --output FILE\n --smoke-frames N Exit desktop after N rendered frames\n --screenshot FILE Save the final desktop frame as PNG\n --preview-mission ID Start a mission directly\n --preview-cinematic KIND intro, landing, death, end1, end2, end3\n --profile-root DIR Use a separate pilot/config/cache directory\n --enabled ID,... Enable mods for headless/replay commands"
|
||||
"raptor-rust [--data DIR] [--mods DIR]\n --verify-assets Check catalog and all original mission definitions\n --validate-mods ID,... Validate selected local mod packages\n --schemas DIR Write mod API JSON schemas\n --headless MISSION --ticks N Deterministic simulation smoke run\n --replay ASSET_NAME --trace FILE Record legacy demo simulation states\n --import-legacy FILE --output FILE\n --smoke-frames N Exit desktop after N rendered frames\n --benchmark Report startup and frame timings (with --smoke-frames)\n --screenshot FILE Save the final desktop frame as PNG\n --preview-mission ID Start a mission directly\n --preview-cinematic KIND intro, landing, death, end1, end2, end3\n --profile-root DIR Use a separate pilot/config/cache directory\n --enabled ID,... Enable mods for headless/replay commands"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -187,6 +192,7 @@ fn run() -> Result<()> {
|
||||
.map(|s| ContentId::new(s))
|
||||
.transpose()?,
|
||||
movie,
|
||||
benchmark,
|
||||
)?;
|
||||
}
|
||||
#[cfg(not(feature = "desktop"))]
|
||||
|
||||
+13
-5
@@ -402,11 +402,19 @@ impl Pilot {
|
||||
p.difficulty <= 3 && p.inventory.len() <= 20,
|
||||
"invalid pilot difficulty/inventory"
|
||||
);
|
||||
ensure!(
|
||||
p.mods == pins,
|
||||
"save requires these mod versions/content: {:?}; enable the matching mods in the Mods screen",
|
||||
p.mods
|
||||
);
|
||||
let mut cursor = 0;
|
||||
for required in &p.mods {
|
||||
let position = pins[cursor..].iter().position(|pin| pin.id == required.id)
|
||||
.with_context(|| format!("save requires mod {} {}; enable the matching mods in the saved load order in the Mods screen", required.id, required.version))? + cursor;
|
||||
ensure!(
|
||||
pins[position] == *required,
|
||||
"save requires unchanged mod {} {} (content hash {}); restore that version/content or select another pilot",
|
||||
required.id,
|
||||
required.version,
|
||||
required.hash
|
||||
);
|
||||
cursor = position + 1;
|
||||
}
|
||||
ensure!(
|
||||
registry.missions.contains_key(&p.mission),
|
||||
"save mission {} is unavailable",
|
||||
|
||||
@@ -4,6 +4,12 @@ use anyhow::{Result, ensure};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::BTreeMap, fs, path::PathBuf};
|
||||
|
||||
/// Original tiny gun rounds and sparse final ground-explosion embers lose
|
||||
/// important pixels under edge filtering; preserve their exact pixel area.
|
||||
pub fn prefers_nearest(asset: u32) -> bool {
|
||||
matches!(asset, 0x101c3..=0x101c6 | 0x10178..=0x1018d)
|
||||
}
|
||||
|
||||
pub fn upscale(sprite: &Sprite, factor: usize) -> Result<Sprite> {
|
||||
ensure!(
|
||||
matches!(factor, 1 | 2 | 3 | 4),
|
||||
@@ -110,6 +116,95 @@ pub fn nearest(sprite: &Sprite, factor: usize) -> Sprite {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Original VGA light/dark lookup. Inputs are six-bit RGB components, not SDL
|
||||
/// eight-bit colors. GFX_Remap uses Manhattan distance and the last equal match.
|
||||
pub fn legacy_light_table(palette: &[[u8; 3]; 256], level: i32) -> [u8; 256] {
|
||||
std::array::from_fn(|index| {
|
||||
let target = palette[index].map(|component| (component as i32 + level).clamp(0, 63));
|
||||
let mut best = 0;
|
||||
let mut distance = i32::MAX;
|
||||
for (candidate, color) in palette.iter().enumerate() {
|
||||
let difference: i32 = (0..3)
|
||||
.map(|channel| (color[channel] as i32 - target[channel]).abs())
|
||||
.sum();
|
||||
if difference <= distance {
|
||||
best = candidate as u8;
|
||||
distance = difference;
|
||||
}
|
||||
}
|
||||
best
|
||||
})
|
||||
}
|
||||
|
||||
/// SHADOW_GAdd / GFX_ShadeShape: preserve native mask spans at offset (-3,+4).
|
||||
pub fn shade_ground_shadow(canvas: &mut Canvas, mask: &Sprite, x: f32, y: f32, table: &[u8; 256]) {
|
||||
let scaled = nearest(mask, canvas.scale);
|
||||
canvas.shade(
|
||||
&scaled,
|
||||
((x - 3.0) * canvas.scale as f32).round() as i32,
|
||||
((y + 4.0) * canvas.scale as f32).round() as i32,
|
||||
table,
|
||||
);
|
||||
}
|
||||
|
||||
fn shadow_project(coordinate: i32, center: i32) -> i32 {
|
||||
// GFX_3DPoint: view=(160,100,1000), objectZ=1280, distance=200.
|
||||
// Keep division before arithmetic shift; negative coordinates round down
|
||||
// after the original signed integer division.
|
||||
((((coordinate as i64 - center as i64) * (200 << 11)) / 280) >> 11) as i32 + center
|
||||
}
|
||||
|
||||
/// SHADOW_Draw's projected horizontal spans, with duplicate projected rows
|
||||
/// suppressed. Fractional positions translate the indexed mask smoothly on the
|
||||
/// high-resolution canvas; integer positions retain the original projection.
|
||||
pub fn shade_air_shadow(canvas: &mut Canvas, mask: &Sprite, x: f32, y: f32, table: &[u8; 256]) {
|
||||
let base_x = x.floor() as i32 - 10;
|
||||
let base_y = y.floor() as i32 + 20;
|
||||
let fraction_x = (x - x.floor()) * (5.0 / 7.0);
|
||||
let fraction_y = (y - y.floor()) * (5.0 / 7.0);
|
||||
let scale = canvas.scale as f32;
|
||||
let mut previous_row = None;
|
||||
for row in 0..mask.height {
|
||||
let source = &mask.pixels[row * mask.width..(row + 1) * mask.width];
|
||||
if source.iter().all(|pixel| *pixel == TRANSPARENT) {
|
||||
continue;
|
||||
}
|
||||
let projected_y = shadow_project(base_y + row as i32, 100);
|
||||
if previous_row == Some(projected_y) {
|
||||
continue;
|
||||
}
|
||||
previous_row = Some(projected_y);
|
||||
let top = ((projected_y as f32 + fraction_y) * scale).round() as i32;
|
||||
let bottom = ((projected_y as f32 + 1.0 + fraction_y) * scale).round() as i32;
|
||||
let mut column = 0;
|
||||
while column < mask.width {
|
||||
if source[column] == TRANSPARENT {
|
||||
column += 1;
|
||||
continue;
|
||||
}
|
||||
let start = column;
|
||||
while column < mask.width && source[column] != TRANSPARENT {
|
||||
column += 1;
|
||||
}
|
||||
let left = ((shadow_project(base_x + start as i32, 160) as f32 + fraction_x) * scale)
|
||||
.round() as i32;
|
||||
let right =
|
||||
((shadow_project(base_x + column as i32 - 1, 160) as f32 + 1.0 + fraction_x)
|
||||
* scale)
|
||||
.round() as i32;
|
||||
for yy in top.max(0)..bottom.min(canvas.image.height as i32) {
|
||||
for xx in left.max(0)..right.min(canvas.image.width as i32) {
|
||||
let pixel =
|
||||
&mut canvas.image.pixels[yy as usize * canvas.image.width + xx as usize];
|
||||
if *pixel < 256 {
|
||||
*pixel = table[*pixel as usize] as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub struct SpriteCache {
|
||||
memory: BTreeMap<String, Sprite>,
|
||||
root: Option<PathBuf>,
|
||||
|
||||
+577
-108
@@ -180,6 +180,10 @@ pub struct Enemy {
|
||||
pub shootagain: i32,
|
||||
pub shootcount: i32,
|
||||
pub shootflag: i32,
|
||||
#[serde(default)]
|
||||
pub energy_resistance: i32,
|
||||
#[serde(default)]
|
||||
pub shoot_disabled: bool,
|
||||
pub kami: u32,
|
||||
pub vx: i32,
|
||||
pub vy: i32,
|
||||
@@ -245,7 +249,128 @@ pub struct LegacyPickup {
|
||||
pub struct Effect {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub px: i32,
|
||||
pub py: i32,
|
||||
pub age: u32,
|
||||
pub kind: EffectKind,
|
||||
pub ground: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum EffectKind {
|
||||
GroundBlast,
|
||||
SmallGround,
|
||||
LargeAir,
|
||||
MediumAir,
|
||||
SmallAir,
|
||||
AirBlast,
|
||||
Energy,
|
||||
Person,
|
||||
Platoon,
|
||||
Flare,
|
||||
Sparkle,
|
||||
}
|
||||
impl EffectKind {
|
||||
pub fn artwork(self) -> (u32, u32) {
|
||||
match self {
|
||||
Self::GroundBlast => (0x10164, 42),
|
||||
Self::SmallGround => (0x1027d, 35),
|
||||
Self::LargeAir => (0x101e5, 12),
|
||||
Self::MediumAir => (0x101d8, 13),
|
||||
Self::SmallAir => (0x101fc, 14),
|
||||
Self::AirBlast => (0x102a0, 16),
|
||||
Self::Energy => (0x101ae, 12),
|
||||
Self::Person => (0x101f5, 7),
|
||||
Self::Platoon => (0x10213, 10),
|
||||
Self::Flare => (0x10252, 26),
|
||||
Self::Sparkle => (0x1026c, 17),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Effect {
|
||||
fn new(kind: EffectKind, x: i32, y: i32) -> Self {
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
px: x,
|
||||
py: y,
|
||||
age: 0,
|
||||
kind,
|
||||
ground: matches!(
|
||||
kind,
|
||||
EffectKind::GroundBlast
|
||||
| EffectKind::SmallGround
|
||||
| EffectKind::MediumAir
|
||||
| EffectKind::Person
|
||||
| EffectKind::Platoon
|
||||
| EffectKind::Flare
|
||||
| EffectKind::Sparkle
|
||||
),
|
||||
}
|
||||
}
|
||||
fn airborne(mut self) -> Self {
|
||||
self.ground = false;
|
||||
self
|
||||
}
|
||||
}
|
||||
fn original_effect(effects: &mut Vec<Effect>, effect: Effect) {
|
||||
if effects.len() < 100 {
|
||||
effects.push(effect);
|
||||
}
|
||||
}
|
||||
fn death_effects(effects: &mut Vec<Effect>, rng: &mut LegacyRng, enemy: &Enemy, explosion: i32) {
|
||||
use EffectKind::*;
|
||||
let cx = enemy.x + enemy.width / 2;
|
||||
let cy = enemy.y + enemy.height / 2;
|
||||
let main = match explosion {
|
||||
0 => MediumAir,
|
||||
1 | 2 | 6 => LargeAir,
|
||||
3 => SmallGround,
|
||||
4 | 5 => GroundBlast,
|
||||
7 => Person,
|
||||
8 => Energy,
|
||||
9 => Platoon,
|
||||
_ => AirBlast,
|
||||
};
|
||||
if explosion == 4 {
|
||||
let x = enemy.x + rng.next_value() % enemy.width.max(1);
|
||||
let y = enemy.y + rng.next_value() % enemy.height.max(1);
|
||||
let kind = if rng.next_value() % 2 == 0 {
|
||||
Sparkle
|
||||
} else {
|
||||
Flare
|
||||
};
|
||||
original_effect(effects, Effect::new(kind, x, y));
|
||||
}
|
||||
original_effect(effects, Effect::new(main, cx, cy));
|
||||
if matches!(explosion, 2 | 5 | 6) {
|
||||
let area = (enemy.width >> 4) * (enemy.height >> 4);
|
||||
for n in 0..area {
|
||||
let x = enemy.x + rng.next_value() % enemy.width.max(1);
|
||||
let y = enemy.y + rng.next_value() % enemy.height.max(1);
|
||||
if explosion == 5 {
|
||||
let kind = if rng.next_value() % 2 == 0 {
|
||||
Flare
|
||||
} else {
|
||||
Sparkle
|
||||
};
|
||||
original_effect(effects, Effect::new(kind, x, y));
|
||||
original_effect(effects, Effect::new(SmallGround, x, y));
|
||||
} else if explosion == 6 {
|
||||
original_effect(effects, Effect::new(Flare, x, y).airborne());
|
||||
original_effect(
|
||||
effects,
|
||||
Effect::new(if n & 1 != 0 { LargeAir } else { AirBlast }, x, y),
|
||||
);
|
||||
} else {
|
||||
let effect = if n & 1 != 0 {
|
||||
Effect::new(MediumAir, x, y)
|
||||
} else {
|
||||
Effect::new(AirBlast, x, y).airborne()
|
||||
};
|
||||
original_effect(effects, effect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
@@ -283,6 +408,14 @@ pub struct World {
|
||||
pub difficulty: u32,
|
||||
pub outcome: Outcome,
|
||||
pub end_timer: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub legacy_transition: bool,
|
||||
#[serde(default = "visible_player")]
|
||||
pub draw_player: bool,
|
||||
#[serde(default)]
|
||||
pub control_paused: bool,
|
||||
#[serde(default)]
|
||||
pub departure_delta: [i32; 2],
|
||||
pub scroll: i32,
|
||||
pub objectives: BTreeMap<String, i32>,
|
||||
pub sounds: Vec<u32>,
|
||||
@@ -296,6 +429,8 @@ pub struct World {
|
||||
pub palette_blue: bool,
|
||||
pub special_held: bool,
|
||||
pub player_flash: bool,
|
||||
#[serde(default = "initial_shield_latch")]
|
||||
pub previous_shield: i32,
|
||||
#[serde(skip)]
|
||||
pub events: Vec<HookEvent>,
|
||||
}
|
||||
@@ -354,6 +489,10 @@ impl World {
|
||||
difficulty: pilot.difficulty,
|
||||
outcome: Outcome::Playing,
|
||||
end_timer: None,
|
||||
legacy_transition: m.legacy_map.is_some(),
|
||||
draw_player: true,
|
||||
control_paused: false,
|
||||
departure_delta: [0, 0],
|
||||
scroll: 0,
|
||||
objectives: BTreeMap::new(),
|
||||
sounds: vec![],
|
||||
@@ -367,6 +506,7 @@ impl World {
|
||||
palette_blue: false,
|
||||
special_held: false,
|
||||
player_flash: false,
|
||||
previous_shield: initial_shield_latch(),
|
||||
events: vec![],
|
||||
};
|
||||
out.event(
|
||||
@@ -467,6 +607,8 @@ impl World {
|
||||
}
|
||||
}),
|
||||
shootflag: f.map_or(0, |f| f[15]),
|
||||
energy_resistance: f.map_or(0, |f| f[13] >> 4),
|
||||
shoot_disabled: false,
|
||||
kami: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
@@ -537,14 +679,37 @@ impl World {
|
||||
self.player_flash = false;
|
||||
self.player.px = self.player.x;
|
||||
self.player.py = self.player.y;
|
||||
if let Some([x, y]) = input.absolute {
|
||||
self.player.x = x.clamp(5, 282);
|
||||
self.player.y = y.clamp(0, 160);
|
||||
let input = if self.legacy_transition && self.control_paused && input.demo_bank.is_none() {
|
||||
Input::default()
|
||||
} else {
|
||||
input
|
||||
};
|
||||
let ending = self.legacy_transition && self.end_timer.is_some();
|
||||
if self.legacy_transition && self.control_paused && input.demo_bank.is_none() {
|
||||
self.player.x += self.departure_delta[0];
|
||||
self.player.y += self.departure_delta[1];
|
||||
} else if let Some([x, y]) = input.absolute {
|
||||
// Legacy demo playback writes recorded positions directly; the
|
||||
// normal input boundary correction is bypassed by DEMO_GetNext.
|
||||
self.player.x = if input.demo_bank.is_some() || ending {
|
||||
x
|
||||
} else {
|
||||
x.clamp(5, 282)
|
||||
};
|
||||
self.player.y = if input.demo_bank.is_some() || ending {
|
||||
y
|
||||
} else {
|
||||
y.clamp(0, 160)
|
||||
};
|
||||
} else {
|
||||
self.player.vx = accelerate(self.player.vx, input.dx, 10);
|
||||
self.player.vy = accelerate(self.player.vy, input.dy, 8);
|
||||
self.player.x = (self.player.x + self.player.vx).clamp(5, 282);
|
||||
self.player.y = (self.player.y + self.player.vy).clamp(0, 160);
|
||||
self.player.x += self.player.vx;
|
||||
self.player.y += self.player.vy;
|
||||
if !ending {
|
||||
self.player.x = self.player.x.clamp(5, 282);
|
||||
self.player.y = self.player.y.clamp(0, 160);
|
||||
}
|
||||
}
|
||||
let delta = ((self.player.x - self.player.px).abs() >> 2).min(3);
|
||||
if let Some(bank) = input.demo_bank {
|
||||
@@ -623,23 +788,13 @@ impl World {
|
||||
}
|
||||
self.special_held = input.special;
|
||||
if input.cycle && !self.cycle_held {
|
||||
let mut specials: Vec<_> = self
|
||||
.player
|
||||
.inventory
|
||||
.keys()
|
||||
.filter(|id| registry.items.get(*id).is_some_and(|i| i.special))
|
||||
.cloned()
|
||||
.collect();
|
||||
specials.sort_by_key(|id| (registry.items[id].legacy.unwrap_or(u32::MAX), id.clone()));
|
||||
if !specials.is_empty() {
|
||||
let next = self
|
||||
.player
|
||||
.selected
|
||||
.as_ref()
|
||||
.and_then(|id| specials.iter().position(|s| s == id))
|
||||
.map_or(0, |i| (i + 1) % specials.len());
|
||||
self.player.selected = Some(specials[next].clone());
|
||||
}
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_SWEP",
|
||||
);
|
||||
self.player.selected = next_special(&self.player, registry);
|
||||
}
|
||||
self.cycle_held = input.cycle;
|
||||
if input.bomb && !self.bomb_held {
|
||||
@@ -655,9 +810,20 @@ impl World {
|
||||
}
|
||||
}
|
||||
self.bomb_held = input.bomb;
|
||||
self.scroll += 1;
|
||||
self.think_legacy_terrain(registry);
|
||||
// Do_Game decrements startendwave after input, before world updates.
|
||||
// At zero it still performs one final simulation/statistics pass.
|
||||
let transition_finished = self.legacy_transition && self.end_timer == Some(0);
|
||||
if self.legacy_transition {
|
||||
self.end_timer = self.end_timer.and_then(|timer| timer.checked_sub(1));
|
||||
}
|
||||
let mission = ®istry.missions[&self.mission];
|
||||
let previous_scroll = self.scroll;
|
||||
self.scroll += 1;
|
||||
if mission.legacy_map.is_some() {
|
||||
let rows = mission.layout.len() / mission.columns.max(1) as usize;
|
||||
self.scroll = self.scroll.min(rows.saturating_sub(8) as i32 * 32 + 56);
|
||||
}
|
||||
self.think_legacy_terrain(registry);
|
||||
while self.spawn_cursor < mission.spawns.len()
|
||||
&& mission.spawns[self.spawn_cursor].tick <= self.tick
|
||||
{
|
||||
@@ -710,17 +876,34 @@ impl World {
|
||||
} else {
|
||||
enemy.x = enemy.movement.x;
|
||||
enemy.y = enemy.movement.y;
|
||||
let left = enemy.movement.advance(def.speed);
|
||||
if enemy.movement.done && !def.path.is_empty() {
|
||||
let kamikaze = def.legacy.as_ref().is_some_and(|l| l.fields[21] == 2);
|
||||
let terminal_kami = kamikaze && enemy.kami == 2;
|
||||
// MoveEobj keeps a terminal kamikaze moving past its target
|
||||
// on subsequent steps; ordinary paths stop and retarget.
|
||||
let left = if terminal_kami && enemy.movement.done {
|
||||
enemy.movement.advance_shot(def.speed);
|
||||
0
|
||||
} else {
|
||||
enemy.movement.advance(def.speed)
|
||||
};
|
||||
if terminal_kami {
|
||||
if enemy.movement.y > 201
|
||||
|| enemy.movement.x > 320 + enemy.width / 2
|
||||
|| enemy.movement.y + enemy.width < 0
|
||||
|| enemy.movement.x + enemy.width < 0
|
||||
{
|
||||
retired.push(enemy.entity);
|
||||
}
|
||||
} else if enemy.movement.done && !def.path.is_empty() {
|
||||
let count = def.path.len();
|
||||
if enemy.path_index >= count {
|
||||
if kamikaze {
|
||||
if enemy.kami == 1 {
|
||||
enemy.kami = 2;
|
||||
}
|
||||
} else if enemy.path_index >= count {
|
||||
if def.repeat {
|
||||
enemy.reversed = true;
|
||||
enemy.path_index = count.saturating_sub(1);
|
||||
} else if def.legacy.as_ref().is_some_and(|l| l.fields[21] == 2)
|
||||
&& enemy.kami < 2
|
||||
{
|
||||
enemy.kami += 1;
|
||||
} else {
|
||||
retired.push(enemy.entity);
|
||||
}
|
||||
@@ -740,7 +923,15 @@ impl World {
|
||||
enemy.movement.initial();
|
||||
}
|
||||
enemy.movement.advance(left);
|
||||
if enemy.reversed {
|
||||
if kamikaze {
|
||||
// F_KAMI arms the chase when the last authored leg
|
||||
// starts, then aims at the player when that leg ends.
|
||||
if enemy.path_index < count - 1 {
|
||||
enemy.path_index += 1;
|
||||
} else if enemy.kami == 0 {
|
||||
enemy.kami = 1;
|
||||
}
|
||||
} else if enemy.reversed {
|
||||
enemy.path_index = enemy.path_index.saturating_sub(1);
|
||||
let min = def
|
||||
.legacy
|
||||
@@ -774,7 +965,11 @@ impl World {
|
||||
enemy.shootflag -= 1;
|
||||
if enemy.shootflag < 0 {
|
||||
enemy.shootflag = l.fields[3];
|
||||
for gun in 0..l.fields[22].max(0) as usize {
|
||||
for gun in 0..if enemy.shoot_disabled {
|
||||
0
|
||||
} else {
|
||||
l.fields[22].max(0) as usize
|
||||
} {
|
||||
shots.push((
|
||||
weapon.clone(),
|
||||
enemy.x + l.gun_x[gun] as i32,
|
||||
@@ -841,8 +1036,9 @@ impl World {
|
||||
continue;
|
||||
}
|
||||
if def.boss && enemy.health < 50 && (self.tick + 1) & 2 != 0 && def.legacy.is_some() {
|
||||
self.rng.next_value();
|
||||
self.rng.next_value();
|
||||
let x = enemy.x + self.rng.next_value() % enemy.width.max(1);
|
||||
let y = enemy.y + self.rng.next_value() % enemy.height.max(1);
|
||||
original_effect(&mut self.effects, Effect::new(EffectKind::SmallAir, x, y));
|
||||
}
|
||||
if !def.ground && contains(enemy, self.player.x + 16, self.player.y + 16) {
|
||||
enemy.health -= 16;
|
||||
@@ -853,10 +1049,12 @@ impl World {
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
self.legacy_transition && self.end_timer.is_some(),
|
||||
);
|
||||
if def.legacy.is_some() {
|
||||
self.rng.next_value();
|
||||
self.rng.next_value();
|
||||
let x = self.player.x + 16 + self.rng.next_value() % 8 - 4;
|
||||
let y = self.player.y + 16 + self.rng.next_value() % 8 - 4;
|
||||
original_effect(&mut self.effects, Effect::new(EffectKind::SmallAir, x, y));
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
@@ -880,22 +1078,22 @@ impl World {
|
||||
self.player.x + 16,
|
||||
self.player.y + 16,
|
||||
);
|
||||
let random_count = match legacy.fields[2] {
|
||||
2 | 6 => 2 * (enemy.width >> 4) * (enemy.height >> 4),
|
||||
4 => 3,
|
||||
5 => 3 * (enemy.width >> 4) * (enemy.height >> 4),
|
||||
_ => 0,
|
||||
};
|
||||
for _ in 0..random_count {
|
||||
self.rng.next_value();
|
||||
}
|
||||
death_effects(&mut self.effects, &mut self.rng, enemy, legacy.fields[2]);
|
||||
} else {
|
||||
original_effect(
|
||||
&mut self.effects,
|
||||
Effect::new(
|
||||
if def.ground {
|
||||
EffectKind::GroundBlast
|
||||
} else {
|
||||
EffectKind::AirBlast
|
||||
},
|
||||
enemy.x + enemy.width / 2,
|
||||
enemy.y + enemy.height / 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
self.player.credits = self.player.credits.saturating_add(def.reward);
|
||||
self.effects.push(Effect {
|
||||
x: enemy.x + enemy.width / 2,
|
||||
y: enemy.y + enemy.height / 2,
|
||||
age: 0,
|
||||
});
|
||||
events.push(HookEvent {
|
||||
content: enemy.definition.clone(),
|
||||
hook: "on_death",
|
||||
@@ -945,6 +1143,16 @@ impl World {
|
||||
}
|
||||
self.events.extend(events);
|
||||
self.enemies.retain(|e| !retired.contains(&e.entity));
|
||||
if self.legacy_transition
|
||||
&& mission.auto_complete
|
||||
&& !retired.is_empty()
|
||||
&& self.spawn_cursor == mission.spawns.len()
|
||||
&& self.enemies.is_empty()
|
||||
{
|
||||
// ENEMY_Remove arms departure before ESHOT_Think, so hostile
|
||||
// shots on the final removal step cannot subtract energy.
|
||||
self.end_timer = Some(60);
|
||||
}
|
||||
for (id, x, y, legacy) in shots {
|
||||
if legacy.is_none_or(|(kind, _, _, _)| kind < 0)
|
||||
|| ((0..320).contains(&x) && (0..200).contains(&y))
|
||||
@@ -990,6 +1198,7 @@ impl World {
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
self.legacy_transition && self.end_timer.is_some(),
|
||||
);
|
||||
dead_shots.push(p.entity);
|
||||
}
|
||||
@@ -1144,11 +1353,10 @@ impl World {
|
||||
.player
|
||||
.credits
|
||||
.saturating_add(mission.tiles[mission.layout[pos]].reward);
|
||||
self.effects.push(Effect {
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
age: 0,
|
||||
});
|
||||
original_effect(
|
||||
&mut self.effects,
|
||||
Effect::new(EffectKind::GroundBlast, p.x, p.y),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1161,24 +1369,28 @@ impl World {
|
||||
|| (p.x >= -32 && p.x <= 352 && p.y >= -64 && p.y <= 240 && p.age < 300))
|
||||
});
|
||||
for e in &mut self.effects {
|
||||
e.px = e.x;
|
||||
e.py = e.y;
|
||||
e.age += 1;
|
||||
if e.ground && self.scroll != previous_scroll && e.age > 1 {
|
||||
e.y += 1;
|
||||
}
|
||||
}
|
||||
self.effects.retain(|e| e.age < 20);
|
||||
self.effects.retain(|e| e.age <= e.kind.artwork().1);
|
||||
self.tick += 1;
|
||||
if self.player.shield <= 0 {
|
||||
if self.legacy_transition {
|
||||
if mission.auto_complete
|
||||
&& self.spawn_cursor == mission.spawns.len()
|
||||
&& self.enemies.is_empty()
|
||||
&& self.end_timer.is_none()
|
||||
&& !transition_finished
|
||||
{
|
||||
self.end_timer = Some(60);
|
||||
}
|
||||
} else if self.player.shield <= 0 {
|
||||
self.outcome = Outcome::Failed;
|
||||
} else if mission.auto_complete {
|
||||
if mission.legacy_map.is_some() {
|
||||
// ENEMY_Remove starts the original 60-tick departure only
|
||||
// after the final spawn has been visited and all ships left.
|
||||
if self.spawn_cursor == mission.spawns.len() && self.enemies.is_empty() {
|
||||
match self.end_timer {
|
||||
None => self.end_timer = Some(60),
|
||||
Some(0) => self.outcome = Outcome::Complete,
|
||||
Some(n) => self.end_timer = Some(n - 1),
|
||||
}
|
||||
}
|
||||
} else if self.tick >= mission.duration
|
||||
if self.tick >= mission.duration
|
||||
&& self.spawn_cursor == mission.spawns.len()
|
||||
&& self
|
||||
.enemies
|
||||
@@ -1188,6 +1400,49 @@ impl World {
|
||||
self.outcome = Outcome::Complete;
|
||||
}
|
||||
}
|
||||
if mission.legacy_map.is_some() && self.tick >= 3 && self.tick % 2 == 1 {
|
||||
// Palette index 254 consumes the original shared RNG, even when no frame is rendered.
|
||||
self.palette_blue = self.rng.next_value() % 3 == 0;
|
||||
}
|
||||
if self.legacy_transition {
|
||||
self.think_legacy_transition(transition_finished, input.demo_bank.is_some());
|
||||
}
|
||||
// RAP_ThinkStats follows palette simulation. A new hit at low energy
|
||||
// destroys the equipped special, or the highest-numbered losable item.
|
||||
// OBJS_LoseObj reports success even when no eligible item remains.
|
||||
if self.player.shield <= 10
|
||||
&& self.player.shield < self.previous_shield
|
||||
&& self.player.super_shield < 1
|
||||
{
|
||||
let lost = self.player.selected.clone().or_else(|| {
|
||||
self.player
|
||||
.inventory
|
||||
.keys()
|
||||
.filter_map(|id| {
|
||||
registry
|
||||
.items
|
||||
.get(id)
|
||||
.and_then(|item| item.legacy)
|
||||
.filter(|legacy| matches!(legacy, 1..=14 | 18..=23))
|
||||
.map(|legacy| (legacy, id.clone()))
|
||||
})
|
||||
.max()
|
||||
.map(|(_, id)| id)
|
||||
});
|
||||
if let Some(id) = lost {
|
||||
let removed = self.player.inventory.remove(&id).is_some();
|
||||
if removed && self.player.selected.as_ref() == Some(&id) {
|
||||
self.player.selected = next_special(&self.player, registry);
|
||||
}
|
||||
}
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_CRASH",
|
||||
);
|
||||
}
|
||||
self.previous_shield = self.player.shield;
|
||||
self.event(self.mission.clone(),"on_tick",serde_json::json!({"tick":self.tick,"player_x":self.player.x,"player_y":self.player.y,"enemies":self.enemies.len()}));
|
||||
if self.outcome != Outcome::Playing {
|
||||
self.event(
|
||||
@@ -1196,12 +1451,113 @@ impl World {
|
||||
serde_json::json!({"success":self.outcome==Outcome::Complete,"tick":self.tick}),
|
||||
);
|
||||
}
|
||||
if mission.legacy_map.is_some() && self.tick >= 3 && self.tick % 2 == 1 {
|
||||
// Palette index 254 consumes the original shared RNG, even when no frame is rendered.
|
||||
self.palette_blue = self.rng.next_value() % 3 == 0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn think_legacy_transition(&mut self, finished: bool, demo: bool) {
|
||||
if self.player.shield <= 0 {
|
||||
// GCC evaluates the original ANIMS_StartAnim position arguments
|
||||
// right-to-left: y consumes the first shared random value.
|
||||
let y = self.player.y + self.rng.next_value() % 32;
|
||||
let x = self.player.x + self.rng.next_value() % 32;
|
||||
original_effect(&mut self.effects, Effect::new(EffectKind::MediumAir, x, y));
|
||||
let y = self.player.y + self.rng.next_value() % 32;
|
||||
let x = self.player.x + self.rng.next_value() % 32;
|
||||
original_effect(&mut self.effects, Effect::new(EffectKind::SmallAir, x, y));
|
||||
if self.end_timer.is_some_and(|timer| timer > 24) {
|
||||
let pan = if self.rng.next_value() % 2 == 0 {
|
||||
30
|
||||
} else {
|
||||
225
|
||||
};
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_AIREXPLO",
|
||||
);
|
||||
if let Some(event) = self.sound_events.last_mut() {
|
||||
event.pan = pan;
|
||||
}
|
||||
}
|
||||
if self.end_timer.is_none() {
|
||||
self.end_timer = Some(60);
|
||||
}
|
||||
if self.end_timer == Some(24) {
|
||||
self.draw_player = false;
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_AIREXPLO",
|
||||
);
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_AIREXPLO2",
|
||||
);
|
||||
original_effect(
|
||||
&mut self.effects,
|
||||
Effect::new(EffectKind::LargeAir, self.player.x + 16, self.player.y + 16),
|
||||
);
|
||||
// Random positions are evaluated even after the animation pool
|
||||
// fills, exactly as the original 512-effect request burst.
|
||||
for index in 0..512 {
|
||||
let x = self.player.x - 16 + self.rng.next_value() % 32 * 2;
|
||||
let y = self.player.y - 16 + self.rng.next_value() % 32 * 2;
|
||||
let kind = if index & 1 != 0 {
|
||||
EffectKind::LargeAir
|
||||
} else {
|
||||
EffectKind::AirBlast
|
||||
};
|
||||
original_effect(&mut self.effects, Effect::new(kind, x, y).airborne());
|
||||
}
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_AIREXPLO2",
|
||||
);
|
||||
}
|
||||
} else if let Some(timer) = self.end_timer {
|
||||
if timer == 40 {
|
||||
self.control_paused = true;
|
||||
self.departure_delta = [
|
||||
self.player.x - self.player.px,
|
||||
self.player.y - self.player.py,
|
||||
];
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_FLYBY",
|
||||
);
|
||||
}
|
||||
if timer < 40 {
|
||||
let dx = if self.player.x < 152 {
|
||||
8
|
||||
} else if self.player.x > 168 {
|
||||
-8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.departure_delta = [dx, -4];
|
||||
if !demo {
|
||||
let old_x = self.player.x;
|
||||
self.player.x += dx;
|
||||
self.player.y -= 4;
|
||||
bank_player(&mut self.player, old_x);
|
||||
}
|
||||
}
|
||||
}
|
||||
if finished {
|
||||
self.outcome = if self.player.shield > 0 {
|
||||
Outcome::Complete
|
||||
} else {
|
||||
Outcome::Failed
|
||||
};
|
||||
}
|
||||
}
|
||||
fn fire(&mut self, id: &ContentId, registry: &Registry) -> Result<()> {
|
||||
let Some(w) = registry.weapons.get(id) else {
|
||||
return Ok(());
|
||||
@@ -1353,6 +1709,7 @@ impl World {
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
self.legacy_transition && self.end_timer.is_some(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -1389,6 +1746,7 @@ impl World {
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
self.legacy_transition && self.end_timer.is_some(),
|
||||
);
|
||||
retired.push(p.entity);
|
||||
}
|
||||
@@ -1472,11 +1830,10 @@ impl World {
|
||||
position,
|
||||
frames: 10,
|
||||
});
|
||||
self.effects.push(Effect {
|
||||
x: x + 16,
|
||||
y: y + 16,
|
||||
age: 0,
|
||||
});
|
||||
original_effect(
|
||||
&mut self.effects,
|
||||
Effect::new(EffectKind::GroundBlast, x + 16, y + 16),
|
||||
);
|
||||
// TILE_Explode changes its collision item immediately. Artwork waits
|
||||
// for the delayed explosion and its second chain-damage pass.
|
||||
self.terrain_dead[position] = true;
|
||||
@@ -1581,6 +1938,23 @@ impl World {
|
||||
// A plotted shot tests its endpoint before retiring, even if MoveSobj
|
||||
// marked it done in the preceding step.
|
||||
if !skip_collision && !lib.effect {
|
||||
// TILE_Bomb runs before ENEMY_DamageGround and affects both
|
||||
// the hit tile and the original left/up/right neighbors.
|
||||
if lib.target == 4 {
|
||||
if let Some(position) = self.legacy_tile_hit(p.x, p.y, registry) {
|
||||
self.terrain_health[position] -= weapon.damage;
|
||||
self.terrain_damage_neighbors(position, weapon.damage, registry);
|
||||
let columns = registry.missions[&self.mission].columns.max(1) as usize;
|
||||
if position > columns {
|
||||
self.terrain_damage_neighbors(
|
||||
position - columns,
|
||||
weapon.damage >> 1,
|
||||
registry,
|
||||
);
|
||||
}
|
||||
state.movement.done = true;
|
||||
}
|
||||
}
|
||||
let enemy = self
|
||||
.enemies
|
||||
.iter_mut()
|
||||
@@ -1590,13 +1964,17 @@ impl World {
|
||||
let ground = registry.enemies[&e.definition].ground;
|
||||
contains(e, p.x, p.y)
|
||||
&& match lib.target {
|
||||
1 => !ground,
|
||||
1 | 5 => !ground,
|
||||
2 | 4 => ground,
|
||||
_ => true,
|
||||
}
|
||||
});
|
||||
if let Some(enemy) = enemy {
|
||||
let damage = if lib.target == 4 { 5 } else { weapon.damage };
|
||||
let damage = match lib.target {
|
||||
4 => 5,
|
||||
5 => 1,
|
||||
_ => weapon.damage,
|
||||
};
|
||||
enemy.health -= damage;
|
||||
if self.difficulty == 0 && matches!(lib.target, 1 | 2) {
|
||||
enemy.health -= damage;
|
||||
@@ -1605,31 +1983,43 @@ impl World {
|
||||
if matches!(lib.target, 0 | 1 | 3) {
|
||||
self.rng.next_value();
|
||||
}
|
||||
if lib.target == 5
|
||||
&& registry.enemies[&enemy.definition]
|
||||
.legacy
|
||||
.as_ref()
|
||||
.is_some_and(|l| l.fields[5] != 0)
|
||||
{
|
||||
if enemy.energy_resistance > 0 {
|
||||
enemy.energy_resistance -= weapon.damage;
|
||||
} else {
|
||||
enemy.shoot_on = false;
|
||||
enemy.shoot_disabled = true;
|
||||
enemy.shootagain = -1;
|
||||
play_legacy_sound(
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
"FX_EGRAB",
|
||||
);
|
||||
position_sound(
|
||||
&mut self.sound_events,
|
||||
enemy.x + enemy.width / 2,
|
||||
enemy.y + enemy.width / 2,
|
||||
self.player.x + 16,
|
||||
self.player.y + 16,
|
||||
);
|
||||
}
|
||||
}
|
||||
let entity = enemy.entity;
|
||||
let enemy_id = enemy.definition.clone();
|
||||
let health = enemy.health;
|
||||
self.event(enemy_id,"on_hit",serde_json::json!({"entity":entity,"projectile":p.entity,"weapon":p.weapon,"damage":damage,"x":p.x,"y":p.y,"health":health}));
|
||||
self.event(p.weapon.clone(),"on_hit",serde_json::json!({"entity":entity,"projectile":p.entity,"damage":damage,"x":p.x,"y":p.y}));
|
||||
} else if matches!(lib.target, 0 | 2 | 4) {
|
||||
let mission = ®istry.missions[&self.mission];
|
||||
let col = (p.x - 16).div_euclid(32);
|
||||
let row = (p.y
|
||||
+ mission.layout.len() as i32 / mission.columns.max(1) as i32 * 32
|
||||
- 200
|
||||
- self.scroll)
|
||||
.div_euclid(32);
|
||||
if col >= 0 && col < mission.columns as i32 && row >= 0 {
|
||||
let pos = row as usize * mission.columns as usize + col as usize;
|
||||
if pos < self.terrain_health.len()
|
||||
&& mission.tiles[mission.layout[pos]].destroyed.is_some()
|
||||
&& !self.terrain_dead[pos]
|
||||
{
|
||||
self.terrain_health[pos] -= weapon.damage;
|
||||
if lib.target != 4 {
|
||||
self.rng.next_value();
|
||||
}
|
||||
state.movement.done = true;
|
||||
}
|
||||
} else if matches!(lib.target, 0 | 2) {
|
||||
if let Some(position) = self.legacy_tile_hit(p.x, p.y, registry) {
|
||||
self.terrain_health[position] -= weapon.damage;
|
||||
self.rng.next_value();
|
||||
state.movement.done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1646,9 +2036,18 @@ impl World {
|
||||
for enemy in &mut self.enemies {
|
||||
enemy.health -= weapon.damage;
|
||||
}
|
||||
for (health, dead) in self.terrain_health.iter_mut().zip(&self.terrain_dead) {
|
||||
if !dead {
|
||||
*health -= weapon.damage;
|
||||
let mission = ®istry.missions[&self.mission];
|
||||
let columns = mission.columns.max(1) as usize;
|
||||
let (row, _) = self.terrain_window(registry);
|
||||
let start = row * columns;
|
||||
let end = (start + 8 * columns).min(mission.layout.len());
|
||||
// TILE_DamageAll uses a fixed 20 terrain damage and
|
||||
// includes the final visible tile, unlike TILE_IsHit.
|
||||
for position in start..end {
|
||||
if mission.tiles[mission.layout[position]].destroyed.is_some()
|
||||
&& !self.terrain_dead[position]
|
||||
{
|
||||
self.terrain_health[position] -= 20;
|
||||
}
|
||||
}
|
||||
retired.extend(
|
||||
@@ -1681,6 +2080,26 @@ impl World {
|
||||
self.projectiles.retain(|p| !retired.contains(&p.entity));
|
||||
Ok(())
|
||||
}
|
||||
fn legacy_tile_hit(&self, x: i32, y: i32, registry: &Registry) -> Option<usize> {
|
||||
let mission = ®istry.missions[&self.mission];
|
||||
let columns = mission.columns.max(1) as usize;
|
||||
let (row, y_offset) = self.terrain_window(registry);
|
||||
let column = (x - 16).div_euclid(32);
|
||||
let local_row = (y - y_offset).div_euclid(32);
|
||||
if column < 0 || column >= columns as i32 || !(0..8).contains(&local_row) {
|
||||
return None;
|
||||
}
|
||||
let slot = local_row as usize * columns + column as usize;
|
||||
// The original TILE_IsHit/TILE_Bomb loop excludes lastspot itself.
|
||||
if slot >= 8 * columns - 1 {
|
||||
return None;
|
||||
}
|
||||
let position = row * columns + slot;
|
||||
(position < mission.layout.len()
|
||||
&& mission.tiles[mission.layout[position]].destroyed.is_some()
|
||||
&& !self.terrain_dead[position])
|
||||
.then_some(position)
|
||||
}
|
||||
fn fire_legacy(
|
||||
&mut self,
|
||||
id: &ContentId,
|
||||
@@ -2005,6 +2424,7 @@ impl World {
|
||||
&mut self.rng,
|
||||
&mut self.sounds,
|
||||
&mut self.sound_events,
|
||||
self.legacy_transition && self.end_timer.is_some(),
|
||||
);
|
||||
} else {
|
||||
let e = self
|
||||
@@ -2086,11 +2506,7 @@ impl World {
|
||||
}
|
||||
Command::Effect { x, y } => {
|
||||
ensure!(self.effects.len() < 4096, "too many effects");
|
||||
self.effects.push(Effect {
|
||||
x: *x,
|
||||
y: *y,
|
||||
age: 0,
|
||||
});
|
||||
self.effects.push(Effect::new(EffectKind::AirBlast, *x, *y));
|
||||
}
|
||||
Command::Sound { asset } => {
|
||||
self.sounds.push(*asset);
|
||||
@@ -2161,6 +2577,55 @@ impl World {
|
||||
serde_json::json!({"tick":self.tick,"rng":self.rng.seed,"player":[self.player.x,self.player.y,self.player.bank,self.player.shield,self.player.super_shield,self.player.credits],"enemies":self.enemies.iter().map(|e|serde_json::json!([registry.enemies[&e.definition].art.as_ref().and_then(|a|a.asset).unwrap_or(0),e.x,e.y,e.health,e.frame,e.movement.x,e.movement.y])).collect::<Vec<_>>(),"projectiles":self.projectiles.iter().filter(|p|p.hostile).chain(self.projectiles.iter().filter(|p|!p.hostile)).map(|p|serde_json::json!([Self::projectile_asset(p,registry).map(i64::from).unwrap_or(-1),p.x,p.y,p.hostile as u32])).collect::<Vec<_>>()})
|
||||
}
|
||||
}
|
||||
fn initial_shield_latch() -> i32 {
|
||||
-1
|
||||
}
|
||||
fn visible_player() -> bool {
|
||||
true
|
||||
}
|
||||
fn bank_player(player: &mut Player, old_x: i32) {
|
||||
let delta = ((player.x - old_x).abs() >> 2).min(3);
|
||||
if player.x < old_x {
|
||||
if player.bank < 3 + delta {
|
||||
player.bank += 1;
|
||||
}
|
||||
} else if player.x > old_x {
|
||||
if player.bank > 3 - delta {
|
||||
player.bank -= 1;
|
||||
}
|
||||
} else {
|
||||
player.bank += (3 - player.bank).signum();
|
||||
}
|
||||
}
|
||||
fn next_special(player: &Player, registry: &Registry) -> Option<ContentId> {
|
||||
let mut specials: Vec<_> = player
|
||||
.inventory
|
||||
.iter()
|
||||
.filter_map(|(id, count)| {
|
||||
registry
|
||||
.items
|
||||
.get(id)
|
||||
.filter(|item| *count > 0 && item.special)
|
||||
.map(|item| ((item.legacy.unwrap_or(u32::MAX), id.clone()), id.clone()))
|
||||
})
|
||||
.collect();
|
||||
specials.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let after = player.selected.as_ref().map(|id| {
|
||||
(
|
||||
registry
|
||||
.items
|
||||
.get(id)
|
||||
.and_then(|item| item.legacy)
|
||||
.unwrap_or(u32::MAX),
|
||||
id.clone(),
|
||||
)
|
||||
});
|
||||
specials
|
||||
.iter()
|
||||
.find(|(key, _)| after.as_ref().is_none_or(|after| key > after))
|
||||
.or_else(|| specials.first())
|
||||
.map(|(_, id)| id.clone())
|
||||
}
|
||||
fn update_legacy_animation(enemy: &mut Enemy, lib: &crate::assets::LegacyEnemy) {
|
||||
let f = &lib.fields;
|
||||
enemy.art_frame = enemy.frame;
|
||||
@@ -2269,7 +2734,11 @@ fn damage_player(
|
||||
rng: &mut LegacyRng,
|
||||
sounds: &mut Vec<u32>,
|
||||
events: &mut Vec<SoundEvent>,
|
||||
immune: bool,
|
||||
) {
|
||||
if immune {
|
||||
return;
|
||||
}
|
||||
let damage = if difficulty == 0 && damage > 1 {
|
||||
damage >> 1
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Original-data integration coverage. Run explicitly with --ignored; no game data is tracked.
|
||||
//! The assisted campaign fixture is described in tools/campaign_check.rs.
|
||||
#[path = "../tools/campaign_check.rs"]
|
||||
mod campaign_check;
|
||||
use raptor_replica::{
|
||||
assets::{Catalog, decode_demo},
|
||||
content::ContentId,
|
||||
modding::ScriptRuntime,
|
||||
persistence::Pilot,
|
||||
simulation::{Input, World},
|
||||
};
|
||||
use std::{collections::BTreeSet, path::PathBuf};
|
||||
|
||||
fn catalog_registry() -> (Catalog, raptor_replica::content::Registry) {
|
||||
let root = std::env::var_os("RAPTOR_TEST_DATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data"));
|
||||
campaign_check::catalog_registry(&root).unwrap()
|
||||
}
|
||||
fn registry() -> raptor_replica::content::Registry {
|
||||
catalog_registry().1
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires original untracked catalog; assisted inspection, not ordinary gameplay wins"]
|
||||
fn every_original_wave_reaches_its_end_on_all_four_difficulties() {
|
||||
let registry = registry();
|
||||
assert_eq!(registry.missions.len(), 27);
|
||||
let mut failures = vec![];
|
||||
let mut reports = vec![];
|
||||
for difficulty in 0..=3 {
|
||||
for sector in 1..=3 {
|
||||
for wave in 1..=9 {
|
||||
let report =
|
||||
campaign_check::run_wave(®istry, sector, wave, difficulty, 12000).unwrap();
|
||||
eprintln!("{}", report.csv());
|
||||
if !report.completed()
|
||||
|| report.visited_spawns != report.total_spawns
|
||||
|| report.bosses_seen != report.expected_bosses
|
||||
{
|
||||
failures.push(format!("{report:?}"));
|
||||
}
|
||||
reports.push(report.csv());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(path) = std::env::var_os("RAPTOR_CAMPAIGN_REPORT") {
|
||||
std::fs::write(
|
||||
path,
|
||||
format!("{}\n{}\n", campaign_check::csv_header(), reports.join("\n")),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"Assisted fixture blockers:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires original untracked catalog; progression fixture bypasses combat"]
|
||||
fn all_original_campaign_and_training_progression_paths_are_available() {
|
||||
let registry = registry();
|
||||
for difficulty in 0..=3 {
|
||||
let mut pilot = Pilot {
|
||||
difficulty,
|
||||
..Pilot::default()
|
||||
};
|
||||
let waves = if difficulty == 0 { 4 } else { 9 };
|
||||
let sectors = if difficulty == 0 { 1 } else { 3 };
|
||||
for sector in 1..=sectors {
|
||||
for wave in 1..=waves {
|
||||
let expected = ContentId::new(format!("base:sector_{sector}_wave_{wave}")).unwrap();
|
||||
assert_eq!(pilot.mission, expected);
|
||||
pilot.complete_mission(®istry).unwrap();
|
||||
pilot.validate(®istry, &[]).unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(pilot.mission, ContentId::from("base:sector_1_wave_1"));
|
||||
assert_eq!(pilot.difficulty, (difficulty + 1).min(3));
|
||||
let progress = pilot.legacy_progress.as_ref().unwrap();
|
||||
assert!(progress.waves.iter().all(|wave| *wave == 0));
|
||||
if difficulty == 0 {
|
||||
assert!(progress.training_completed);
|
||||
assert_eq!(pilot.credits, 10000);
|
||||
assert_eq!(pilot.inventory[&"base:energy".into()], 75);
|
||||
} else {
|
||||
assert_eq!(pilot.completed.len(), 27);
|
||||
assert_eq!(&progress.difficulties[..3], &[(difficulty + 1).min(3); 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires original untracked catalog; assisted weapon exercise, no weapon parity claim"]
|
||||
fn every_original_player_weapon_emits_with_live_mission_targets() {
|
||||
let registry = registry();
|
||||
let weapons: Vec<_> = registry
|
||||
.items
|
||||
.values()
|
||||
.filter(|i| i.weapon.is_some() && i.legacy.is_some())
|
||||
.collect();
|
||||
assert_eq!(weapons.len(), 15);
|
||||
let mut exercised = BTreeSet::new();
|
||||
for item in weapons {
|
||||
let id = item.weapon.clone().unwrap();
|
||||
let mut pilot = Pilot {
|
||||
difficulty: 3,
|
||||
selected: Some(item.id.clone()),
|
||||
..Pilot::default()
|
||||
};
|
||||
pilot.inventory = [
|
||||
(item.id.clone(), item.max_count),
|
||||
("base:energy".into(), 100),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
for tick in 0..1500 {
|
||||
world.player.shield = 100;
|
||||
world.player.super_shield = 100;
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
absolute: Some([144, 160]),
|
||||
bomb: tick % 180 == 0,
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
if world.projectiles.iter().any(|p| p.weapon == id) {
|
||||
exercised.insert(id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
exercised.contains(&id),
|
||||
"original weapon {id} produced no projectile with mission targets"
|
||||
);
|
||||
}
|
||||
assert_eq!(exercised.len(), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires original catalog and rebuilt C++ full-demo reference trace"]
|
||||
fn complete_demo_1_matches_every_reference_gameplay_snapshot() {
|
||||
let path = std::env::var_os("RAPTOR_REFERENCE_DEMO1")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".reference/cpp-demo1-full.jsonl")
|
||||
});
|
||||
let reference: Vec<serde_json::Value> = std::fs::read_to_string(&path)
|
||||
.expect("produce the trace using the command in docs/campaign-validation.md")
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
reference.len(),
|
||||
1889,
|
||||
"expected the entire available DEMO1G1 recording"
|
||||
);
|
||||
let (catalog, registry) = catalog_registry();
|
||||
let (sector, wave, frames) = decode_demo(&catalog.named("DEMO1G1_REC").unwrap()).unwrap();
|
||||
assert_eq!(frames.len(), reference.len());
|
||||
let mut pilot = Pilot::demo(sector, ®istry).unwrap();
|
||||
pilot.mission = format!("base:sector_{}_wave_{}", sector + 1, wave + 1)
|
||||
.as_str()
|
||||
.into();
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
for (frame, expected) in frames.into_iter().zip(reference) {
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: frame.buttons[0],
|
||||
cycle: frame.buttons[1],
|
||||
bomb: frame.buttons[2],
|
||||
absolute: Some([frame.x as i32, frame.y as i32]),
|
||||
demo_bank: Some(frame.bank as i32),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
world.reference_state(®istry),
|
||||
expected,
|
||||
"reference mismatch at simulation step {}",
|
||||
world.tick
|
||||
);
|
||||
}
|
||||
assert_eq!(world.tick, 1889);
|
||||
assert_eq!(world.player.shield, 3);
|
||||
assert_eq!(world.player.credits, 60825);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Opt-in ordinary-input victory check against the installed original catalog.
|
||||
use raptor_replica::{
|
||||
assets::{Catalog, decode_demo},
|
||||
content::Registry,
|
||||
modding::ScriptRuntime,
|
||||
persistence::Pilot,
|
||||
simulation::{Input, Outcome, World},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
const MISSION: &str = "base:sector_1_wave_1";
|
||||
const ORIGINAL_FLYBY_SOUND: u32 = 129;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct DepartureObservation {
|
||||
tick: u64,
|
||||
timer: Option<u32>,
|
||||
x: i32,
|
||||
y: i32,
|
||||
draw_player: bool,
|
||||
control_paused: bool,
|
||||
sounds: Vec<u32>,
|
||||
}
|
||||
|
||||
fn target_x(world: &World, registry: &Registry) -> i32 {
|
||||
let center = world.player.x + 16;
|
||||
world
|
||||
.enemies
|
||||
.iter()
|
||||
.filter(|e| e.health > 0 && e.y + e.height > 0 && e.y < world.player.y)
|
||||
.min_by_key(|e| {
|
||||
let def = ®istry.enemies[&e.definition];
|
||||
(
|
||||
!def.boss,
|
||||
(e.x + e.width / 2 - center).abs() + (160 - e.y).max(0) / 3,
|
||||
)
|
||||
})
|
||||
.map_or(160, |e| {
|
||||
// Lead the current observed motion by the forward projectile's travel time.
|
||||
let travel = ((world.player.y - e.y - e.height / 2) / 8).clamp(0, 24);
|
||||
(e.x + e.width / 2 + (e.x - e.px).clamp(-4, 4) * travel).clamp(21, 298)
|
||||
})
|
||||
}
|
||||
|
||||
fn controller(world: &World, registry: &Registry) -> Input {
|
||||
let aim = target_x(world, registry);
|
||||
if world.enemies.is_empty() && !world.projectiles.iter().any(|p| p.hostile) {
|
||||
return Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
};
|
||||
}
|
||||
let mut best = None;
|
||||
for dy in [0, -1, 1] {
|
||||
for dx in [0, -1, 1] {
|
||||
let input = Input {
|
||||
dx,
|
||||
dy,
|
||||
fire: true,
|
||||
..Input::default()
|
||||
};
|
||||
// These are disposable predictions. The actual world changes only
|
||||
// through normal keyboard movement and the held fire button.
|
||||
let mut prediction = world.clone();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
for future in 0..12 {
|
||||
let projected = if future < 3 {
|
||||
input
|
||||
} else {
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
}
|
||||
};
|
||||
prediction.step(projected, registry, &mut scripts).unwrap();
|
||||
}
|
||||
let lost = (world.player.shield + world.player.super_shield
|
||||
- prediction.player.shield
|
||||
- prediction.player.super_shield)
|
||||
.max(0) as i64;
|
||||
let position = prediction.player.x + 16 + prediction.player.vx * 2;
|
||||
let mut cost = lost * 1_000_000
|
||||
+ (position - aim).abs() as i64 * 80
|
||||
+ (160 - prediction.player.y).abs() as i64 * 6;
|
||||
if prediction.outcome == Outcome::Failed {
|
||||
cost += 1_000_000_000;
|
||||
}
|
||||
for p in prediction.projectiles.iter().filter(|p| p.hostile) {
|
||||
let distance = (p.x - prediction.player.x - 16)
|
||||
.abs()
|
||||
.max((p.y - prediction.player.y - 16).abs());
|
||||
cost += (40 - distance).max(0) as i64 * 30;
|
||||
}
|
||||
let gained = prediction
|
||||
.player
|
||||
.credits
|
||||
.saturating_sub(world.player.credits);
|
||||
cost -= gained as i64;
|
||||
if best.as_ref().is_none_or(|(previous, _)| cost < *previous) {
|
||||
best = Some((cost, input));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.unwrap().1
|
||||
}
|
||||
|
||||
fn demo(frames: &[[u8; 12]]) -> Vec<u8> {
|
||||
assert!(frames.len() <= i16::MAX as usize);
|
||||
let mut data = vec![0_u8; 12];
|
||||
data[8..10].copy_from_slice(&(frames.len() as i16).to_le_bytes());
|
||||
for frame in frames {
|
||||
data.extend_from_slice(frame);
|
||||
}
|
||||
let (sector, wave, decoded) = decode_demo(&data).unwrap();
|
||||
assert_eq!((sector, wave, decoded.len()), (0, 0, frames.len()));
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires locally installed original data; ordinary-input first mission victory"]
|
||||
fn original_first_mission_is_won_with_a_fresh_pilot() {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let path = std::env::var_os("RAPTOR_TEST_DATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| root.join("data"));
|
||||
let catalog = Catalog::open(&path).unwrap();
|
||||
let registry = Registry::base(&catalog).unwrap();
|
||||
let reference = root.join(".reference");
|
||||
fs::create_dir_all(&reference).unwrap();
|
||||
for difficulty in [1, 2] {
|
||||
let pilot = Pilot {
|
||||
difficulty,
|
||||
..Pilot::default()
|
||||
};
|
||||
pilot.validate(®istry, &[]).unwrap();
|
||||
let mut world = World::new(&MISSION.into(), &pilot, ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
let mut bosses = BTreeMap::new();
|
||||
let mut visible_bosses = BTreeMap::new();
|
||||
let mut killed = BTreeMap::new();
|
||||
// A removed entity with positive prior health is not credited as a
|
||||
// kill. This is conservative around same-step collision deaths.
|
||||
let mut positive_retirements = BTreeMap::new();
|
||||
let mut frames = Vec::new();
|
||||
let mut controls = Vec::new();
|
||||
let mut observations = Vec::new();
|
||||
let mut snapshots = Vec::new();
|
||||
let mut departure = Vec::new();
|
||||
for _ in 0..12_000 {
|
||||
if world.outcome != Outcome::Playing {
|
||||
break;
|
||||
}
|
||||
let input = controller(&world, ®istry);
|
||||
let prior: Vec<_> = world
|
||||
.enemies
|
||||
.iter()
|
||||
.map(|e| (e.entity, e.definition.clone(), e.health))
|
||||
.collect();
|
||||
let before = world.player.shield;
|
||||
let before_credits = world.player.credits;
|
||||
let before_timer = world.end_timer;
|
||||
world.step(input, ®istry, &mut scripts).unwrap();
|
||||
if world.end_timer.is_some() || before_timer.is_some() {
|
||||
departure.push(DepartureObservation {
|
||||
tick: world.tick,
|
||||
timer: world.end_timer,
|
||||
x: world.player.x,
|
||||
y: world.player.y,
|
||||
draw_player: world.draw_player,
|
||||
control_paused: world.control_paused,
|
||||
sounds: world.sounds.clone(),
|
||||
});
|
||||
}
|
||||
for e in &world.enemies {
|
||||
if registry.enemies[&e.definition].boss {
|
||||
bosses
|
||||
.entry(e.entity)
|
||||
.or_insert_with(|| (e.definition.clone(), world.tick));
|
||||
if e.health > 0
|
||||
&& e.y + e.height > 0
|
||||
&& e.y < 200
|
||||
&& e.x + e.width > 0
|
||||
&& e.x < 320
|
||||
{
|
||||
visible_bosses.entry(e.entity).or_insert(world.tick);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (entity, definition, health) in prior {
|
||||
if !world.enemies.iter().any(|e| e.entity == entity) {
|
||||
if health <= 0 {
|
||||
let reward = registry.enemies[&definition].reward;
|
||||
if bosses.contains_key(&entity) {
|
||||
assert!(
|
||||
world.player.credits >= before_credits + reward,
|
||||
"original boss death did not award its reward"
|
||||
);
|
||||
}
|
||||
killed.insert(entity, json!({"definition":definition,"tick":world.tick,"last_health":health,"reward":reward}));
|
||||
} else {
|
||||
positive_retirements.insert(entity, (definition, health));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut frame = [0_u8; 12];
|
||||
frame[0] = u8::from(input.fire);
|
||||
frame[1] = u8::from(input.cycle);
|
||||
frame[2] = u8::from(input.bomb);
|
||||
frame[4..6].copy_from_slice(&(world.player.x as i16).to_le_bytes());
|
||||
frame[6..8].copy_from_slice(&(world.player.y as i16).to_le_bytes());
|
||||
frame[8..10].copy_from_slice(&(world.player.bank as i16).to_le_bytes());
|
||||
frames.push(frame);
|
||||
controls.push(json!({"tick":world.tick,"dx":input.dx,"dy":input.dy,"fire":input.fire}));
|
||||
snapshots.push(world.reference_state(®istry));
|
||||
if world.tick % 100 == 0
|
||||
|| before != world.player.shield
|
||||
|| world.outcome != Outcome::Playing
|
||||
{
|
||||
observations.push(json!({"tick":world.tick,"x":world.player.x,"y":world.player.y,"shield":world.player.shield,"credits":world.player.credits,"enemies":world.enemies.iter().map(|e| json!({"entity":e.entity,"id":e.definition,"health":e.health,"x":e.x,"y":e.y})).collect::<Vec<_>>()}));
|
||||
}
|
||||
}
|
||||
let cue = departure
|
||||
.iter()
|
||||
.find(|observation| observation.timer == Some(40))
|
||||
.expect("successful original mission skipped its departure cue");
|
||||
let report = json!({"mission":MISSION,"initial_pilot":pilot,"difficulty":difficulty,"ticks":world.tick,"demo_recorded_steps":cue.tick,"outcome":world.outcome,"shield":world.player.shield,"credits":world.player.credits,"spawns":world.spawn_cursor,"total_spawns":registry.missions[&world.mission].spawns.len(),"bosses":bosses,"bosses_first_visible":visible_bosses,"killed":killed,"positive_health_retirements":positive_retirements,"departure":departure,"observations":observations});
|
||||
let name = format!("first-mission-fresh-difficulty-{difficulty}");
|
||||
// The original DEMO_Think records before the stats-stage forced exit
|
||||
// movement. End-of-step coordinates are equivalent until timer40.
|
||||
// Keep this legacy recording to that boundary; the keyboard log below
|
||||
// independently reproduces the complete ordinary departure.
|
||||
let recorded = demo(&frames[..cue.tick as usize]);
|
||||
fs::write(reference.join(format!("{name}.dem")), &recorded).unwrap();
|
||||
fs::write(
|
||||
reference.join(format!("{name}-controls.json")),
|
||||
serde_json::to_vec(&controls).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
reference.join(format!("{name}-report.json")),
|
||||
serde_json::to_vec_pretty(&report).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut trace =
|
||||
BufWriter::new(fs::File::create(reference.join(format!("{name}.jsonl"))).unwrap());
|
||||
for snapshot in &snapshots {
|
||||
writeln!(&mut trace, "{snapshot}").unwrap();
|
||||
}
|
||||
trace.flush().unwrap();
|
||||
eprintln!(
|
||||
"First original mission difficulty {difficulty}: {:?}, {} steps, shield {}, credits {}, bosses {:?}, confirmed kills {}, positive-health retirements {}",
|
||||
world.outcome,
|
||||
world.tick,
|
||||
world.player.shield,
|
||||
world.player.credits,
|
||||
bosses,
|
||||
killed.len(),
|
||||
positive_retirements.len()
|
||||
);
|
||||
assert_eq!(world.outcome, Outcome::Complete, "{report}");
|
||||
let mission = ®istry.missions[&world.mission];
|
||||
assert_eq!(world.spawn_cursor, mission.spawns.len());
|
||||
let expected_bosses = mission
|
||||
.spawns
|
||||
.iter()
|
||||
.filter(|spawn| {
|
||||
registry.enemies[&spawn.enemy].boss
|
||||
&& match spawn.difficulty {
|
||||
3 => true,
|
||||
4 => difficulty >= 2,
|
||||
5 => difficulty >= 3,
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
.count();
|
||||
assert!(
|
||||
expected_bosses > 0,
|
||||
"catalog did not contain an original boss"
|
||||
);
|
||||
assert_eq!(
|
||||
bosses.len(),
|
||||
expected_bosses,
|
||||
"some original bosses never appeared"
|
||||
);
|
||||
assert_eq!(
|
||||
visible_bosses.len(),
|
||||
bosses.len(),
|
||||
"some bosses never entered the playfield alive"
|
||||
);
|
||||
assert!(
|
||||
bosses.keys().all(|entity| killed.contains_key(entity)),
|
||||
"an original boss escaped alive: {report}"
|
||||
);
|
||||
assert!(
|
||||
world.player.credits > pilot.credits,
|
||||
"victory must award original rewards"
|
||||
);
|
||||
assert!(cue.sounds.contains(&ORIGINAL_FLYBY_SOUND));
|
||||
assert!(cue.control_paused);
|
||||
let remaining_24 = departure
|
||||
.iter()
|
||||
.find(|observation| observation.timer == Some(24))
|
||||
.expect("successful original mission skipped its departure animation");
|
||||
assert!(
|
||||
remaining_24.draw_player,
|
||||
"a surviving pilot was hidden like a dead pilot"
|
||||
);
|
||||
assert!(
|
||||
world.player.y < 0,
|
||||
"surviving pilot never flew off the top of the screen"
|
||||
);
|
||||
// Legacy playback must preserve every snapshot through the boss kill
|
||||
// and flyby cue, before automatic departure changes the record phase.
|
||||
let (_, _, replay) = decode_demo(&recorded).unwrap();
|
||||
let mut replay_world = World::new(&MISSION.into(), &pilot, ®istry).unwrap();
|
||||
let mut replay_scripts = ScriptRuntime::new();
|
||||
for (frame, expected) in replay.into_iter().zip(&snapshots) {
|
||||
replay_world
|
||||
.step(
|
||||
Input {
|
||||
fire: frame.buttons[0],
|
||||
cycle: frame.buttons[1],
|
||||
bomb: frame.buttons[2],
|
||||
absolute: Some([frame.x as i32, frame.y as i32]),
|
||||
demo_bank: Some(frame.bank as i32),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut replay_scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
replay_world.reference_state(®istry),
|
||||
*expected,
|
||||
"recorded first-mission input diverged at step {}",
|
||||
replay_world.tick
|
||||
);
|
||||
}
|
||||
assert_eq!(replay_world.tick, cue.tick);
|
||||
assert_eq!(replay_world.end_timer, Some(40));
|
||||
assert!(replay_world.enemies.is_empty());
|
||||
// The keyboard log is also independently decoded and replayed. Unlike
|
||||
// the original demo format, it includes ordinary departure movement.
|
||||
let keyboard: Vec<serde_json::Value> = serde_json::from_slice(
|
||||
&fs::read(reference.join(format!("{name}-controls.json"))).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut keyboard_world = World::new(&MISSION.into(), &pilot, ®istry).unwrap();
|
||||
let mut keyboard_scripts = ScriptRuntime::new();
|
||||
assert_eq!(keyboard.len(), snapshots.len());
|
||||
for (control, expected) in keyboard.into_iter().zip(snapshots) {
|
||||
keyboard_world
|
||||
.step(
|
||||
Input {
|
||||
dx: control["dx"].as_i64().unwrap() as i32,
|
||||
dy: control["dy"].as_i64().unwrap() as i32,
|
||||
fire: control["fire"].as_bool().unwrap(),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut keyboard_scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
keyboard_world.reference_state(®istry),
|
||||
expected,
|
||||
"keyboard recording diverged at step {}",
|
||||
keyboard_world.tick
|
||||
);
|
||||
}
|
||||
assert_eq!(keyboard_world.outcome, Outcome::Complete);
|
||||
}
|
||||
}
|
||||
+581
-1
@@ -2,7 +2,7 @@ use raptor_replica::{
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{Command, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, LineMove, World},
|
||||
simulation::{Input, LegacyProjectile, LineMove, Projectile, World},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -24,6 +24,36 @@ fn fixture() -> Registry {
|
||||
reg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_coordinates_bypass_normal_input_bounds() {
|
||||
let reg = fixture();
|
||||
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([284, 162]),
|
||||
demo_bank: Some(3),
|
||||
..Input::default()
|
||||
},
|
||||
®,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((world.player.x, world.player.y), (284, 162));
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([284, 162]),
|
||||
..Input::default()
|
||||
},
|
||||
®,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((world.player.x, world.player.y), (282, 160));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_guns_use_banked_muzzles_and_accelerate_after_position_snapshot() {
|
||||
let reg = fixture();
|
||||
@@ -184,3 +214,553 @@ fn consumable_use_has_snapshot_and_only_fires_on_press() {
|
||||
assert_eq!(world.player.credits, 14);
|
||||
assert_eq!(world.player.selected, None);
|
||||
}
|
||||
|
||||
fn loss_fixture() -> Registry {
|
||||
let mut registry = fixture();
|
||||
registry.apply(serde_json::from_value(json!({"items":[
|
||||
{"id":"base:dumb_missile","name":"Dumb Missile","price":0,"special":true,"legacy":3},
|
||||
{"id":"base:mini_gun","name":"Mini Gun","price":0,"special":true,"legacy":4},
|
||||
{"id":"base:death_ray","name":"Death Ray","price":0,"special":true,"legacy":14},
|
||||
{"id":"base:detect","name":"Detector","price":0,"legacy":17},
|
||||
{"id":"base:itembuy6","name":"Cash Cargo","price":0,"max_count":50,"legacy":23}
|
||||
]})).unwrap(), "base").unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn low_shield_world(registry: &Registry) -> (World, ScriptRuntime) {
|
||||
let mut pilot = Pilot {
|
||||
difficulty: 3,
|
||||
..Pilot::default()
|
||||
};
|
||||
for id in [
|
||||
"base:dumb_missile",
|
||||
"base:mini_gun",
|
||||
"base:death_ray",
|
||||
"base:detect",
|
||||
] {
|
||||
pilot.inventory.insert(id.into(), 1);
|
||||
}
|
||||
pilot.inventory.insert("base:itembuy6".into(), 50);
|
||||
let mut world = World::new(&pilot.mission, &pilot, registry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(Input::default(), registry, &mut scripts)
|
||||
.unwrap();
|
||||
(world, scripts)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_shield_damage_loses_selected_weapon_then_cycles_in_legacy_order() {
|
||||
let registry = loss_fixture();
|
||||
let (mut world, mut scripts) = low_shield_world(®istry);
|
||||
world.player.selected = Some("base:death_ray".into());
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity: 0,
|
||||
amount: 92,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
let seed = world.rng.seed;
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.shield, 8);
|
||||
assert!(
|
||||
!world
|
||||
.player
|
||||
.inventory
|
||||
.contains_key(&"base:death_ray".into())
|
||||
);
|
||||
assert_eq!(world.player.selected, Some("base:dumb_missile".into()));
|
||||
assert_eq!(
|
||||
world.player.inventory[&ContentId::from("base:itembuy6")],
|
||||
50
|
||||
);
|
||||
assert!(world.sounds.contains(&119)); // FX_CRASH has no random pitch.
|
||||
assert_eq!(world.rng.seed, seed);
|
||||
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity: 0,
|
||||
amount: 1,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!world
|
||||
.player
|
||||
.inventory
|
||||
.contains_key(&"base:dumb_missile".into())
|
||||
);
|
||||
assert_eq!(world.player.selected, Some("base:mini_gun".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_shield_without_selection_loses_highest_legacy_item_and_protects_essentials() {
|
||||
let registry = loss_fixture();
|
||||
let (mut world, mut scripts) = low_shield_world(®istry);
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity: 0,
|
||||
amount: 92,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert!(!world.player.inventory.contains_key(&"base:itembuy6".into()));
|
||||
for id in [
|
||||
"base:forward_guns",
|
||||
"base:energy",
|
||||
"base:detect",
|
||||
"base:death_ray",
|
||||
] {
|
||||
assert!(
|
||||
world.player.inventory.contains_key(&id.into()),
|
||||
"protected or lower-numbered item {id} lost"
|
||||
);
|
||||
}
|
||||
assert_eq!(world.player.selected, None);
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert!(
|
||||
world
|
||||
.player
|
||||
.inventory
|
||||
.contains_key(&"base:death_ray".into())
|
||||
);
|
||||
assert!(!world.sounds.contains(&119)); // Remaining at low shield does not lose another item.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_energy_with_super_shield_and_damage_above_threshold_preserve_equipment() {
|
||||
let registry = loss_fixture();
|
||||
let (mut world, mut scripts) = low_shield_world(®istry);
|
||||
world.player.selected = Some("base:death_ray".into());
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity: 0,
|
||||
amount: 89,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.shield, 11);
|
||||
assert!(
|
||||
world
|
||||
.player
|
||||
.inventory
|
||||
.contains_key(&"base:death_ray".into())
|
||||
);
|
||||
world.player.super_shield = 5;
|
||||
world
|
||||
.apply_commands(&[Command::Shield { amount: -3 }], ®istry)
|
||||
.unwrap();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.shield, 8);
|
||||
assert!(
|
||||
world
|
||||
.player
|
||||
.inventory
|
||||
.contains_key(&"base:death_ray".into())
|
||||
);
|
||||
assert!(!world.sounds.contains(&119));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_kamikaze_arms_chase_when_final_leg_starts_and_crosses_target() {
|
||||
let mut registry = fixture();
|
||||
let enemy = registry
|
||||
.enemies
|
||||
.get_mut(&ContentId::from("base:drone"))
|
||||
.unwrap();
|
||||
enemy.health = 10000;
|
||||
enemy.speed = 4;
|
||||
enemy.path = vec![[0, -114], [0, -96]];
|
||||
let mut fields = vec![0; 26];
|
||||
fields[7] = 1;
|
||||
fields[9] = 1;
|
||||
fields[18] = 4;
|
||||
fields[19] = 2;
|
||||
fields[21] = 2; // Original F_KAMI.
|
||||
enemy.legacy = Some(raptor_replica::assets::LegacyEnemy {
|
||||
name: "Synthetic Kamikaze".into(),
|
||||
fields,
|
||||
shoot_type: vec![0; 24],
|
||||
engine_x: vec![0; 24],
|
||||
engine_y: vec![0; 24],
|
||||
engine_width: vec![0; 24],
|
||||
gun_x: vec![0; 24],
|
||||
gun_y: vec![0; 24],
|
||||
flight_x: vec![0; 32],
|
||||
flight_y: vec![0; 32],
|
||||
});
|
||||
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap();
|
||||
let entity = world
|
||||
.spawn(&"base:drone".into(), 48, -131, ®istry)
|
||||
.unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
let mut armed = false;
|
||||
let mut crossed = false;
|
||||
for _ in 0..120 {
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([96, 136]),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
let Some(enemy) = world.enemies.iter().find(|e| e.entity == entity) else {
|
||||
break;
|
||||
};
|
||||
if enemy.kami == 1 {
|
||||
armed = true;
|
||||
assert_eq!(enemy.movement.ty, -12);
|
||||
assert!(!enemy.movement.done);
|
||||
assert_eq!(enemy.path_index, 1);
|
||||
}
|
||||
if enemy.kami == 2 {
|
||||
assert_eq!((enemy.movement.tx, enemy.movement.ty), (112, 152));
|
||||
crossed |= enemy.movement.y > 152;
|
||||
}
|
||||
}
|
||||
assert!(armed, "last authored leg never armed chase");
|
||||
assert!(crossed, "terminal chase stopped at its target");
|
||||
assert!(
|
||||
!world.enemies.iter().any(|e| e.entity == entity),
|
||||
"terminal kamikaze failed to retire offscreen"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_beam_hits_launch_column_before_following_player_for_display() {
|
||||
let mut registry = fixture();
|
||||
registry.apply(serde_json::from_value(json!({
|
||||
"weapons":[{"id":"base:projectile_death_ray","name":"Death Ray","damage":6,"speed":0,"cooldown":7,"behavior":"beam","target":"all","legacy":14,"art":{"asset":50,"logical_width":2,"logical_height":8,"frames":4}}],
|
||||
"items":[{"id":"base:death_ray","name":"Death Ray","price":0,"special":true,"legacy":14,"weapon":"base:projectile_death_ray"}]
|
||||
})).unwrap(), "base").unwrap();
|
||||
let pilot = Pilot {
|
||||
selected: Some("base:death_ray".into()),
|
||||
inventory: [("base:death_ray".into(), 1), ("base:energy".into(), 100)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
..Pilot::default()
|
||||
};
|
||||
let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap();
|
||||
world
|
||||
.spawn(&"base:drone".into(), 157, 40, ®istry)
|
||||
.unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.enemies[0].health, 14);
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([141, 160]),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.projectiles[0].x, 156); // Followed artwork is outside the enemy.
|
||||
assert_eq!(world.enemies[0].health, 8); // Damage still tests launch column159.
|
||||
}
|
||||
|
||||
fn collision_fixture() -> Registry {
|
||||
let mut registry = fixture();
|
||||
registry.apply(serde_json::from_value(json!({"weapons":[
|
||||
{"id":"base:projectile_energy_grab","name":"Energy Grab","damage":3,"speed":4,"cooldown":2,"behavior":"straight","target":"air","legacy":10,"art":{"asset":51,"logical_width":8,"logical_height":8,"frames":6}},
|
||||
{"id":"base:projectile_ground_bomb","name":"Ground Bomb","damage":50,"speed":1,"cooldown":30,"behavior":"straight","target":"ground","legacy":9,"art":{"asset":52,"logical_width":8,"logical_height":8}},
|
||||
{"id":"base:projectile_mega_bomb","name":"Mega Bomb","damage":50,"speed":2,"cooldown":60,"behavior":"straight","target":"all","legacy":11,"art":{"asset":53,"logical_width":8,"logical_height":8,"frames":4}},
|
||||
{"id":"base:hostile","name":"Hostile","damage":2,"speed":2,"cooldown":1,"behavior":"straight","target":"all"}
|
||||
],"enemies":[
|
||||
{"id":"base:ground","name":"Ground Target","health":30,"ground":true},
|
||||
{"id":"base:air","name":"Air Target","health":64,"speed":0,"path":[[0,200]],"weapon":"base:hostile"}
|
||||
]})).unwrap(), "base").unwrap();
|
||||
let air = registry
|
||||
.enemies
|
||||
.get_mut(&ContentId::from("base:air"))
|
||||
.unwrap();
|
||||
let mut fields = vec![0; 26];
|
||||
fields[5] = 1; // Native suck susceptibility.
|
||||
fields[7] = 1;
|
||||
fields[9] = 1;
|
||||
fields[13] = 64;
|
||||
fields[16] = 1;
|
||||
fields[21] = 3;
|
||||
fields[22] = 1;
|
||||
air.legacy = Some(raptor_replica::assets::LegacyEnemy {
|
||||
name: "Synthetic Susceptible Air Target".into(),
|
||||
fields,
|
||||
shoot_type: vec![1; 24],
|
||||
engine_x: vec![0; 24],
|
||||
engine_y: vec![0; 24],
|
||||
engine_width: vec![0; 24],
|
||||
gun_x: vec![16; 24],
|
||||
gun_y: vec![16; 24],
|
||||
flight_x: vec![0; 32],
|
||||
flight_y: vec![0; 32],
|
||||
});
|
||||
registry
|
||||
}
|
||||
|
||||
fn inject_legacy_shot(
|
||||
world: &mut World,
|
||||
id: &str,
|
||||
x: i32,
|
||||
y: i32,
|
||||
done: bool,
|
||||
registry: &Registry,
|
||||
) -> u64 {
|
||||
let weapon = ®istry.weapons[&ContentId::from(id)];
|
||||
let art = weapon.art.as_ref().unwrap();
|
||||
let mut movement = LineMove::new(
|
||||
x + art.logical_width as i32 / 2,
|
||||
y + art.logical_height as i32 / 2,
|
||||
x,
|
||||
0,
|
||||
);
|
||||
movement.done = done;
|
||||
let entity = world.next_entity;
|
||||
world.next_entity += 1;
|
||||
world.projectiles.push(Projectile {
|
||||
entity,
|
||||
weapon: id.into(),
|
||||
x,
|
||||
y,
|
||||
px: x,
|
||||
py: y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
hostile: false,
|
||||
age: 0,
|
||||
legacy_enemy: None,
|
||||
legacy: Some(LegacyProjectile {
|
||||
movement,
|
||||
speed: weapon.speed,
|
||||
frame: 0,
|
||||
art_frame: 0,
|
||||
delay: false,
|
||||
hit: false,
|
||||
start: [world.player.x + 16, world.player.y + 16],
|
||||
}),
|
||||
});
|
||||
entity
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn energy_grab_hits_air_by_one_and_suppresses_fire_after_resistance_is_exhausted() {
|
||||
let registry = collision_fixture();
|
||||
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap();
|
||||
world
|
||||
.spawn(&"base:ground".into(), 64, 64, ®istry)
|
||||
.unwrap();
|
||||
let entity = world.spawn(&"base:air".into(), 64, 64, ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
for (expected_health, expected_resistance, disabled) in
|
||||
[(63, 1, false), (62, -2, false), (61, -2, true)]
|
||||
{
|
||||
inject_legacy_shot(
|
||||
&mut world,
|
||||
"base:projectile_energy_grab",
|
||||
80,
|
||||
80,
|
||||
false,
|
||||
®istry,
|
||||
);
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
let air = world.enemies.iter().find(|e| e.entity == entity).unwrap();
|
||||
assert_eq!(air.health, expected_health);
|
||||
assert_eq!(air.energy_resistance, expected_resistance);
|
||||
assert_eq!(air.shoot_disabled, disabled);
|
||||
assert_eq!(world.enemies[0].health, 30); // Ground target overlaps the shot.
|
||||
}
|
||||
assert!(world.sounds.contains(&134)); // FX_EGRAB.
|
||||
let hostile = world.projectiles.iter().filter(|p| p.hostile).count();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
world.projectiles.iter().filter(|p| p.hostile).count(),
|
||||
hostile
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.enemies
|
||||
.iter()
|
||||
.find(|e| e.entity == entity)
|
||||
.unwrap()
|
||||
.shoot_disabled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn energy_grab_does_not_disable_an_immune_air_enemy() {
|
||||
let mut registry = collision_fixture();
|
||||
registry
|
||||
.enemies
|
||||
.get_mut(&"base:air".into())
|
||||
.unwrap()
|
||||
.legacy
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.fields[5] = 0;
|
||||
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap();
|
||||
world.spawn(&"base:air".into(), 64, 64, ®istry).unwrap();
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
for _ in 0..5 {
|
||||
inject_legacy_shot(
|
||||
&mut world,
|
||||
"base:projectile_energy_grab",
|
||||
80,
|
||||
80,
|
||||
false,
|
||||
®istry,
|
||||
);
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(world.enemies[0].health, 59);
|
||||
assert_eq!(world.enemies[0].energy_resistance, 4);
|
||||
assert!(!world.enemies[0].shoot_disabled);
|
||||
}
|
||||
|
||||
fn terrain_fixture() -> Registry {
|
||||
let mut registry = collision_fixture();
|
||||
let pack = serde_json::from_value(json!({"missions":[{
|
||||
"id":"base:sector_1_wave_1","name":"Terrain Fixture","duration":1000,"auto_complete":false,"legacy_map":123,"columns":3,"layout":vec![0;30],
|
||||
"tiles":[
|
||||
{"art":{"asset":60,"logical_width":32,"logical_height":32},"health":200,"destroyed":{"asset":61,"logical_width":32,"logical_height":32}},
|
||||
{"art":{"asset":62,"logical_width":32,"logical_height":32},"health":200}
|
||||
]
|
||||
}],"overrides":["base:sector_1_wave_1"]})).unwrap();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ground_bomb_damages_tile_neighbors_and_overlapping_ground_enemy_together() {
|
||||
let registry = terrain_fixture();
|
||||
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap();
|
||||
world
|
||||
.spawn(&"base:ground".into(), 48, 73, ®istry)
|
||||
.unwrap();
|
||||
let shot = inject_legacy_shot(
|
||||
&mut world,
|
||||
"base:projectile_ground_bomb",
|
||||
64,
|
||||
89,
|
||||
false,
|
||||
®istry,
|
||||
);
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.enemies[0].health, 25);
|
||||
assert!(!world.projectiles.iter().any(|p| p.entity == shot));
|
||||
for position in [19, 18, 16, 20] {
|
||||
assert_eq!(
|
||||
world.terrain_health[position], 150,
|
||||
"primary neighbor {position}"
|
||||
);
|
||||
}
|
||||
for position in [15, 13, 17] {
|
||||
assert_eq!(
|
||||
world.terrain_health[position], 175,
|
||||
"upper neighbor {position}"
|
||||
);
|
||||
}
|
||||
assert_eq!(world.terrain_health[22], 200);
|
||||
assert_eq!(world.terrain_health[21], 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mega_bomb_uses_visible_destructible_tiles_at_fixed_twenty_damage_and_clears_shots() {
|
||||
let mut registry = terrain_fixture();
|
||||
registry
|
||||
.missions
|
||||
.get_mut(&Pilot::default().mission)
|
||||
.unwrap()
|
||||
.layout[8] = 1;
|
||||
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap();
|
||||
world.terrain_dead[7] = true;
|
||||
world
|
||||
.spawn(&"base:ground".into(), 64, 64, ®istry)
|
||||
.unwrap();
|
||||
let hostile = world.next_entity;
|
||||
world.next_entity += 1;
|
||||
world.projectiles.push(Projectile {
|
||||
entity: hostile,
|
||||
weapon: "base:hostile".into(),
|
||||
x: 100,
|
||||
y: 100,
|
||||
px: 100,
|
||||
py: 100,
|
||||
vx: 0,
|
||||
vy: 1,
|
||||
hostile: true,
|
||||
age: 0,
|
||||
legacy: None,
|
||||
legacy_enemy: None,
|
||||
});
|
||||
inject_legacy_shot(
|
||||
&mut world,
|
||||
"base:projectile_mega_bomb",
|
||||
100,
|
||||
100,
|
||||
true,
|
||||
®istry,
|
||||
);
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.enemies[0].health, -20); // Original enemy damage50, separate from tiles.
|
||||
assert!(world.projectiles.is_empty());
|
||||
for position in 0..30 {
|
||||
let expected = if position < 6 || position == 7 || position == 8 {
|
||||
200
|
||||
} else {
|
||||
180
|
||||
};
|
||||
assert_eq!(
|
||||
world.terrain_health[position], expected,
|
||||
"terrain position{position}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+48
-2
@@ -24,11 +24,15 @@ const MISSION: &str = "iron_coast_mission_1:mission_01";
|
||||
|
||||
fn base() -> Registry {
|
||||
let mut registry = Registry::default();
|
||||
let pack:ContentPack=serde_json::from_value(json!({
|
||||
let mut value = json!({
|
||||
"weapons":[{"id":"base:projectile_forward_guns","name":"Forward Guns","damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all","legacy":0,"art":{"asset":42,"logical_width":4,"logical_height":8,"frames":4}}],
|
||||
"items":[{"id":"base:forward_guns","name":"Forward Guns","price":0,"max_count":1,"weapon":"base:projectile_forward_guns","legacy":0},{"id":"base:energy","name":"Shield","price":0,"max_count":100,"legacy":16}],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Base fixture","duration":1000}]
|
||||
})).unwrap();
|
||||
});
|
||||
for n in 0..18 {
|
||||
value["items"].as_array_mut().unwrap().push(json!({"id":format!("base:fixture_item_{n}"),"name":"Synthetic equipment","price":0,"max_count":1}));
|
||||
}
|
||||
let pack: ContentPack = serde_json::from_value(value).unwrap();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
registry.validate().unwrap();
|
||||
registry
|
||||
@@ -89,6 +93,7 @@ fn fixture_resources(value: &Value, root: &Path) {
|
||||
}
|
||||
|
||||
fn active() -> (tempfile::TempDir, ActiveMods) {
|
||||
let started = std::time::Instant::now();
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/mods");
|
||||
for id in PACKAGES {
|
||||
@@ -114,6 +119,10 @@ fn active() -> (tempfile::TempDir, ActiveMods) {
|
||||
assert!(discovery.errors.is_empty(), "{:?}", discovery.errors);
|
||||
let enabled = PACKAGES.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let active = ActiveMods::build(&base(), &discovery, &enabled, None).unwrap();
|
||||
eprintln!(
|
||||
"Iron Coast synthetic seven-package activation: {:.3}s",
|
||||
started.elapsed().as_secs_f64()
|
||||
);
|
||||
(temporary, active)
|
||||
}
|
||||
|
||||
@@ -210,9 +219,46 @@ fn play(use_trial: bool) -> (World, Vec<(u64, String)>) {
|
||||
let recovered: serde_json::Value = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(recovered["pulse_cannon/scripts/weapon.rhai"], *weapon_state);
|
||||
}
|
||||
eprintln!(
|
||||
"Iron Coast {} run: tick={} outcome={:?} shield={} credits={} repairs_remaining={}",
|
||||
if use_trial {
|
||||
"trial Pulse"
|
||||
} else {
|
||||
"starter guns"
|
||||
},
|
||||
world.tick,
|
||||
world.outcome,
|
||||
world.player.shield,
|
||||
world.player.credits,
|
||||
world
|
||||
.player
|
||||
.inventory
|
||||
.get(&"iron_coast_mission_1:repair".into())
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
);
|
||||
(world, seen)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_inventory_can_start_without_deleting_saved_equipment() {
|
||||
let (_temporary, mut active) = active();
|
||||
let mut pilot = Pilot::default();
|
||||
for n in 0..18 {
|
||||
pilot
|
||||
.inventory
|
||||
.insert(format!("base:fixture_item_{n}").as_str().into(), 1);
|
||||
}
|
||||
assert_eq!(pilot.inventory.len(), 20);
|
||||
let mut world = World::new(&MISSION.into(), &pilot, &active.registry).unwrap();
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.inventory, pilot.inventory);
|
||||
assert_eq!(world.objectives["iron_coast_mission_1:trial_issued"], 0);
|
||||
assert_eq!(world.outcome, Outcome::Playing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restarted_mission_counts_new_turret_kills_independently_of_saved_statistics() {
|
||||
let (_temporary, mut active) = active();
|
||||
|
||||
@@ -501,6 +501,37 @@ fn missing_hook_does_not_advance_rng_or_state() {
|
||||
assert!(runtime.rng.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_allow_added_mods_while_preserving_required_versions_and_order() {
|
||||
let registry = base();
|
||||
let pin = |id: &str| modding::ModPin {
|
||||
id: id.into(),
|
||||
version: "1.0.0".into(),
|
||||
hash: format!("{id}-content"),
|
||||
};
|
||||
let a = pin("alpha");
|
||||
let b = pin("beta");
|
||||
let c = pin("extra");
|
||||
let mut pilot = Pilot::default();
|
||||
assert!(pilot.validate(®istry, &[a.clone(), b.clone()]).is_ok());
|
||||
pilot.mods = vec![a.clone(), b.clone()];
|
||||
pilot
|
||||
.script_state
|
||||
.insert("alpha/ai.rhai".into(), json!({"kills": 17}));
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("pilot.json");
|
||||
pilot.save(&path).unwrap();
|
||||
let before = fs::read(&path).unwrap();
|
||||
let loaded = Pilot::load(&path, ®istry, &[a.clone(), c, b.clone()]).unwrap();
|
||||
assert_eq!(loaded.script_state, pilot.script_state);
|
||||
assert!(Pilot::load(&path, ®istry, &[b.clone(), a.clone()]).is_err());
|
||||
assert!(Pilot::load(&path, ®istry, &[a.clone()]).is_err());
|
||||
let mut changed = b;
|
||||
changed.version = "2.0.0".into();
|
||||
assert!(Pilot::load(&path, ®istry, &[a, changed]).is_err());
|
||||
assert_eq!(fs::read(path).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incompatible_save_pins_and_disabled_equipment_do_not_modify_the_save() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
use raptor_replica::{
|
||||
content::{ContentPack, Registry},
|
||||
modding::{Command, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{EffectKind, Input, LegacyRng, Outcome, Projectile, World},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn fixture(legacy: bool, automatic: bool) -> (Registry, World, ScriptRuntime) {
|
||||
let pack: ContentPack = serde_json::from_value(json!({
|
||||
"weapons":[{"id":"base:shot","name":"Shot","damage":8,"speed":0,"cooldown":1,"behavior":"straight","target":"all"}],
|
||||
"items":[{"id":"base:forward_guns","name":"Guns","price":0,"weapon":"base:shot","legacy":0},{"id":"base:energy","name":"Shield","price":0,"max_count":100,"legacy":16}],
|
||||
"enemies":[{"id":"base:target","name":"Final Target","health":1,"ground":true,"reward":10}],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Transition Fixture","duration":1,"auto_complete":automatic,"legacy_map":if legacy {Some(123)} else {None}}]
|
||||
})).unwrap();
|
||||
let mut registry = Registry::default();
|
||||
registry.apply(pack, "base").unwrap();
|
||||
let world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap();
|
||||
(registry, world, ScriptRuntime::new())
|
||||
}
|
||||
|
||||
fn after_calls(mut rng: LegacyRng, calls: usize) -> i32 {
|
||||
for _ in 0..calls {
|
||||
rng.next_value();
|
||||
}
|
||||
rng.seed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_departure_pauses_at_forty_and_flies_off_before_completion() {
|
||||
let (registry, mut world, mut scripts) = fixture(true, false);
|
||||
world.end_timer = Some(60);
|
||||
for step in 1..=20 {
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.end_timer, Some(60 - step));
|
||||
assert_eq!(world.outcome, Outcome::Playing);
|
||||
assert!(world.draw_player);
|
||||
assert_eq!((world.player.x, world.player.y), (144, 160));
|
||||
assert_eq!(world.control_paused, step == 20);
|
||||
assert_eq!(world.sounds.contains(&129), step == 20); // FX_FLYBY.
|
||||
}
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([280, 0]),
|
||||
fire: true,
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((world.player.x, world.player.y), (152, 156));
|
||||
assert!(world.projectiles.is_empty()); // Paused input cannot fire.
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!((world.player.x, world.player.y), (160, 148)); // Input+stats movement.
|
||||
while world.outcome == Outcome::Playing {
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(world.tick, 61);
|
||||
assert_eq!(world.outcome, Outcome::Complete);
|
||||
assert_eq!((world.player.x, world.player.y), (160, -160));
|
||||
assert!(world.draw_player);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_departure_positions_are_not_moved_a_second_time() {
|
||||
let (registry, mut world, mut scripts) = fixture(true, false);
|
||||
world.end_timer = Some(39);
|
||||
world.control_paused = true;
|
||||
world.departure_delta = [8, -4];
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([155, -8]),
|
||||
demo_bank: Some(2),
|
||||
..Input::default()
|
||||
},
|
||||
®istry,
|
||||
&mut scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(world.player.x, world.player.y, world.player.bank),
|
||||
(155, -8, 2)
|
||||
);
|
||||
assert_eq!(world.end_timer, Some(38));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_enemy_removal_arms_immunity_before_hostile_shot_updates() {
|
||||
let (registry, mut world, mut scripts) = fixture(true, true);
|
||||
let entity = world
|
||||
.spawn(&"base:target".into(), 20, 20, ®istry)
|
||||
.unwrap();
|
||||
world
|
||||
.apply_commands(&[Command::Damage { entity, amount: 1 }], ®istry)
|
||||
.unwrap();
|
||||
world.projectiles.push(Projectile {
|
||||
entity: world.next_entity,
|
||||
weapon: "base:shot".into(),
|
||||
x: 160,
|
||||
y: 176,
|
||||
px: 160,
|
||||
py: 176,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
hostile: true,
|
||||
age: 0,
|
||||
legacy: None,
|
||||
legacy_enemy: None,
|
||||
});
|
||||
world.next_entity += 1;
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.end_timer, Some(60));
|
||||
assert_eq!(world.player.shield, 100);
|
||||
assert_eq!(world.player.credits, 10);
|
||||
assert_eq!(world.outcome, Outcome::Playing);
|
||||
assert!(world.projectiles.is_empty());
|
||||
let seed = world.rng.seed;
|
||||
world
|
||||
.apply_commands(
|
||||
&[Command::Damage {
|
||||
entity: 0,
|
||||
amount: 500,
|
||||
}],
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.shield, 100);
|
||||
assert_eq!(world.rng.seed, seed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_death_delays_failure_hides_ship_at_twenty_four_and_consumes_full_burst_rng() {
|
||||
let (registry, mut world, mut scripts) = fixture(true, false);
|
||||
world.player.shield = 0;
|
||||
let seed = world.rng;
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.tick, 1);
|
||||
assert_eq!(world.end_timer, Some(60));
|
||||
assert_eq!(world.outcome, Outcome::Playing);
|
||||
assert!(world.draw_player);
|
||||
assert_eq!(world.rng.seed, after_calls(seed, 4)); // Two x/y positions, no early sound.
|
||||
assert_eq!(world.effects.len(), 2);
|
||||
assert_eq!(world.effects[0].kind, EffectKind::MediumAir);
|
||||
assert_eq!(world.effects[1].kind, EffectKind::SmallAir);
|
||||
let mut positions = seed;
|
||||
let y = 160 + positions.next_value() % 32;
|
||||
let x = 144 + positions.next_value() % 32;
|
||||
assert_eq!((world.effects[0].x, world.effects[0].y), (x, y));
|
||||
let seed = world.rng;
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.rng.seed, after_calls(seed, 6)); // Two positions, sound pan and pitch.
|
||||
assert!(
|
||||
world
|
||||
.sound_events
|
||||
.iter()
|
||||
.any(|s| s.asset == 104 && matches!(s.pan, 30 | 225))
|
||||
);
|
||||
while world.end_timer != Some(25) {
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert!(world.draw_player);
|
||||
}
|
||||
let seed = world.rng;
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.end_timer, Some(24));
|
||||
assert!(!world.draw_player);
|
||||
assert_eq!(world.effects.len(), 100); // Original pool cap, despite512requests.
|
||||
assert_eq!(world.rng.seed, after_calls(seed, 1032)); // Palette1,positions4,pitches3,burst1024.
|
||||
assert_eq!(world.sounds.iter().filter(|s| **s == 104).count(), 1);
|
||||
assert_eq!(world.sounds.iter().filter(|s| **s == 109).count(), 2);
|
||||
while world.outcome == Outcome::Playing {
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert!(world.tick <= 62);
|
||||
}
|
||||
assert_eq!(world.tick, 62);
|
||||
assert_eq!(world.outcome, Outcome::Failed);
|
||||
assert!(!world.draw_player);
|
||||
assert!(!world.control_paused);
|
||||
let stopped = world.digest();
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.digest(), stopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_mod_missions_keep_their_immediate_outcome_contract() {
|
||||
let (registry, mut world, mut scripts) = fixture(false, true);
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.outcome, Outcome::Complete);
|
||||
assert_eq!(world.end_timer, None);
|
||||
let (registry, mut world, mut scripts) = fixture(false, false);
|
||||
world.player.shield = 0;
|
||||
world
|
||||
.step(Input::default(), ®istry, &mut scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.outcome, Outcome::Failed);
|
||||
assert_eq!(world.end_timer, None);
|
||||
}
|
||||
@@ -0,0 +1,852 @@
|
||||
//! Indexed visual contracts and independent ScaleNx specification oracle.
|
||||
//! Original-data contact sheets are opt-in and remain under ignored .reference/.
|
||||
use raptor_replica::{
|
||||
assets::{Catalog, Sprite, TRANSPARENT},
|
||||
content::Registry,
|
||||
render::{
|
||||
BitmapFont, Canvas, SpriteCache, legacy_light_table, nearest, prefers_nearest,
|
||||
shade_air_shadow, shade_ground_shadow, upscale,
|
||||
},
|
||||
};
|
||||
use std::{collections::BTreeSet, fs, path::Path};
|
||||
|
||||
// Uses independent per-corner consensus rules rather than the renderer's shared
|
||||
// optimized guard. Source specification: https://www.scale2x.it/algorithm
|
||||
fn oracle(source: &Sprite, factor: usize) -> Sprite {
|
||||
if factor == 1 {
|
||||
return source.clone();
|
||||
}
|
||||
if factor == 4 {
|
||||
return oracle(&oracle(source, 2), 2);
|
||||
}
|
||||
let mut result = Sprite::blank(source.width * factor, source.height * factor);
|
||||
for y in 0..source.height {
|
||||
for x in 0..source.width {
|
||||
let read = |dx: i32, dy: i32| {
|
||||
let column = (x as i32 + dx).max(0).min(source.width as i32 - 1) as usize;
|
||||
let row = (y as i32 + dy).max(0).min(source.height as i32 - 1) as usize;
|
||||
source.pixels[row * source.width + column]
|
||||
};
|
||||
let [a, b, c, d, e, f, g, h, i] = [
|
||||
read(-1, -1),
|
||||
read(0, -1),
|
||||
read(1, -1),
|
||||
read(-1, 0),
|
||||
read(0, 0),
|
||||
read(1, 0),
|
||||
read(-1, 1),
|
||||
read(0, 1),
|
||||
read(1, 1),
|
||||
];
|
||||
let northwest = d == b && b != f && d != h;
|
||||
let northeast = b == f && b != d && f != h;
|
||||
let southwest = d == h && d != b && h != f;
|
||||
let southeast = h == f && d != h && b != f;
|
||||
let select = |condition, replacement| if condition { replacement } else { e };
|
||||
let values = if factor == 2 {
|
||||
vec![
|
||||
select(northwest, d),
|
||||
select(northeast, f),
|
||||
select(southwest, d),
|
||||
select(southeast, f),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
select(northwest, d),
|
||||
select((northwest && e != c) || (northeast && e != a), b),
|
||||
select(northeast, f),
|
||||
select((northwest && e != g) || (southwest && e != a), d),
|
||||
e,
|
||||
select((northeast && e != i) || (southeast && e != c), f),
|
||||
select(southwest, d),
|
||||
select((southwest && e != i) || (southeast && e != g), h),
|
||||
select(southeast, f),
|
||||
]
|
||||
};
|
||||
for yy in 0..factor {
|
||||
for xx in 0..factor {
|
||||
result.pixels[(y * factor + yy) * result.width + x * factor + xx] =
|
||||
values[yy * factor + xx];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn crop(source: &Sprite, x: usize, y: usize, width: usize, height: usize) -> Sprite {
|
||||
let mut out = Sprite::blank(width, height);
|
||||
for row in 0..height {
|
||||
out.pixels[row * width..(row + 1) * width].copy_from_slice(
|
||||
&source.pixels[(y + row) * source.width + x..(y + row) * source.width + x + width],
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn pattern(width: usize, height: usize, mut seed: u32) -> Sprite {
|
||||
let pixels = (0..width * height)
|
||||
.map(|_| {
|
||||
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
[0, 1, 245, 255, TRANSPARENT][(seed >> 16) as usize % 5]
|
||||
})
|
||||
.collect();
|
||||
Sprite {
|
||||
width,
|
||||
height,
|
||||
pixels,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_three_symbol_neighborhoods_match_unoptimized_published_oracle() {
|
||||
// 3^9 arrangements cover every local equality class for two colors plus
|
||||
// transparency; additional multi-color/border patterns are tested below.
|
||||
for mut code in 0..3_usize.pow(9) {
|
||||
let pixels = (0..9)
|
||||
.map(|_| {
|
||||
let value = [0, 245, TRANSPARENT][code % 3];
|
||||
code /= 3;
|
||||
value
|
||||
})
|
||||
.collect();
|
||||
let source = Sprite {
|
||||
width: 3,
|
||||
height: 3,
|
||||
pixels,
|
||||
};
|
||||
for factor in [2, 3, 4] {
|
||||
assert_eq!(upscale(&source, factor).unwrap(), oracle(&source, factor));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamped_borders_and_single_pixel_dimensions_match_oracle() {
|
||||
for (width, height) in [(1, 1), (1, 9), (9, 1), (2, 2), (7, 11)] {
|
||||
for seed in 0..20 {
|
||||
let source = pattern(width, height, seed);
|
||||
for factor in [1, 2, 3, 4] {
|
||||
assert_eq!(upscale(&source, factor).unwrap(), oracle(&source, factor));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_copies_palette_labels_and_keeps_opaque_black_and_white() {
|
||||
let source = Sprite {
|
||||
width: 3,
|
||||
height: 2,
|
||||
pixels: vec![0, 255, TRANSPARENT, 245, 0, 255],
|
||||
};
|
||||
let colors: BTreeSet<_> = source.pixels.iter().copied().collect();
|
||||
for factor in [1, 2, 3, 4] {
|
||||
let scaled = upscale(&source, factor).unwrap();
|
||||
assert!(scaled.pixels.iter().all(|value| colors.contains(value)));
|
||||
assert!(scaled.pixels.contains(&0));
|
||||
assert!(scaled.pixels.contains(&255));
|
||||
assert!(scaled.pixels.contains(&TRANSPARENT));
|
||||
assert_eq!(source.width, 3);
|
||||
assert_eq!(source.height, 2);
|
||||
}
|
||||
let mut canvas = Canvas::new(1);
|
||||
canvas.clear(37);
|
||||
canvas.blit(&source, 4, 5);
|
||||
let mut first = [[0; 3]; 256];
|
||||
first[0] = [1, 2, 3];
|
||||
first[255] = [250, 251, 252];
|
||||
first[245] = [200, 90, 10];
|
||||
first[37] = [8, 9, 10];
|
||||
let pixels_before = canvas.image.pixels.clone();
|
||||
let rgba_first = canvas.rgba(&first, false);
|
||||
first[245] = [10, 90, 200];
|
||||
let rgba_second = canvas.rgba(&first, false);
|
||||
assert_eq!(canvas.image.pixels, pixels_before);
|
||||
assert_ne!(rgba_first, rgba_second);
|
||||
assert_eq!(
|
||||
&rgba_second[((5 * 320 + 4) * 4)..((5 * 320 + 4) * 4 + 3)],
|
||||
&[1, 2, 3]
|
||||
);
|
||||
assert_eq!(canvas.image.pixels[5 * 320 + 6], 37);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_transparent_sprites_clip_cleanly_at_every_viewport_edge() {
|
||||
let source = pattern(7, 9, 19);
|
||||
for factor in [1, 2, 3, 4] {
|
||||
let sprite = upscale(&source, factor).unwrap();
|
||||
let width = 320 * factor;
|
||||
let height = 200 * factor;
|
||||
for (x, y) in [
|
||||
(-(sprite.width as i32) + 1, -(sprite.height as i32) + 1),
|
||||
(-3, 20),
|
||||
(30, -4),
|
||||
(width as i32 - 1, height as i32 - 1),
|
||||
(width as i32 - 4, 30),
|
||||
(40, height as i32 - 4),
|
||||
(-100, -100),
|
||||
] {
|
||||
let mut canvas = Canvas::new(factor);
|
||||
canvas.clear(37);
|
||||
canvas.blit(&sprite, x, y);
|
||||
for yy in 0..height {
|
||||
for xx in 0..width {
|
||||
let sx = xx as i32 - x;
|
||||
let sy = yy as i32 - y;
|
||||
let expected = if sx >= 0
|
||||
&& sy >= 0
|
||||
&& sx < sprite.width as i32
|
||||
&& sy < sprite.height as i32
|
||||
{
|
||||
let pixel = sprite.pixels[sy as usize * sprite.width + sx as usize];
|
||||
if pixel == TRANSPARENT { 37 } else { pixel }
|
||||
} else {
|
||||
37
|
||||
};
|
||||
assert_eq!(canvas.image.pixels[yy * width + xx], expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_pixel_terrain_context_matches_full_joined_filter_at_all_scales() {
|
||||
let source = pattern(18, 18, 28);
|
||||
let with_gutter = crop(&source, 4, 4, 10, 10);
|
||||
for factor in [2, 3, 4] {
|
||||
let full = upscale(&source, factor).unwrap();
|
||||
let gutter = upscale(&with_gutter, factor).unwrap();
|
||||
assert_eq!(
|
||||
crop(&full, 6 * factor, 6 * factor, 6 * factor, 6 * factor),
|
||||
crop(&gutter, 2 * factor, 2 * factor, 6 * factor, 6 * factor)
|
||||
);
|
||||
assert_eq!(gutter, oracle(&with_gutter, factor));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_tile_filtering_is_detected_by_joined_seam_regression() {
|
||||
let source = pattern(8, 6, 1);
|
||||
let left = crop(&source, 0, 0, 4, 6);
|
||||
let right = crop(&source, 4, 0, 4, 6);
|
||||
for factor in [2, 3, 4] {
|
||||
let joined = upscale(&source, factor).unwrap();
|
||||
let a = upscale(&left, factor).unwrap();
|
||||
let b = upscale(&right, factor).unwrap();
|
||||
let mut tiled = Sprite::blank(8 * factor, 6 * factor);
|
||||
for row in 0..tiled.height {
|
||||
tiled.pixels[row * tiled.width..row * tiled.width + a.width]
|
||||
.copy_from_slice(&a.pixels[row * a.width..(row + 1) * a.width]);
|
||||
tiled.pixels[row * tiled.width + a.width..(row + 1) * tiled.width]
|
||||
.copy_from_slice(&b.pixels[row * b.width..(row + 1) * b.width]);
|
||||
}
|
||||
assert_ne!(joined, tiled, "fixture must expose a seam at scale{factor}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_text_keeps_exact_square_pixel_blocks_at_every_scale() {
|
||||
let mut bytes = vec![0; 772];
|
||||
bytes[..4].copy_from_slice(&2_i32.to_le_bytes());
|
||||
bytes[516 + b'A' as usize] = 3;
|
||||
bytes.extend([1, 0, 1, 0, 1, 0]);
|
||||
let font = BitmapFont::decode(&bytes).unwrap();
|
||||
assert_eq!(font.height(), 2);
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let mut canvas = Canvas::new(scale);
|
||||
canvas.clear(37);
|
||||
font.text(&mut canvas, 10, 20, "A", 100);
|
||||
for y in 0..2 * scale {
|
||||
for x in 0..3 * scale {
|
||||
let expected = if [1, 0, 1, 0, 1, 0][y / scale * 3 + x / scale] == 0 {
|
||||
37
|
||||
} else {
|
||||
100
|
||||
};
|
||||
assert_eq!(
|
||||
canvas.image.pixels[(20 * scale + y) * canvas.image.width + 10 * scale + x],
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
canvas
|
||||
.image
|
||||
.pixels
|
||||
.iter()
|
||||
.filter(|pixel| **pixel == 100)
|
||||
.count(),
|
||||
3 * scale * scale
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_shadow_masks_clip_without_shading_transparent_pixels() {
|
||||
let mask = Sprite {
|
||||
width: 3,
|
||||
height: 3,
|
||||
pixels: vec![
|
||||
TRANSPARENT,
|
||||
1,
|
||||
TRANSPARENT,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
TRANSPARENT,
|
||||
1,
|
||||
TRANSPARENT,
|
||||
],
|
||||
};
|
||||
let mut canvas = Canvas::new(2);
|
||||
canvas.clear(10);
|
||||
let mut lookup = std::array::from_fn(|index| index as u8);
|
||||
lookup[10] = 11;
|
||||
let y = canvas.image.height as i32 - 2;
|
||||
canvas.shade(&mask, -1, y, &lookup);
|
||||
for yy in 0..canvas.image.height {
|
||||
for x in 0..canvas.image.width {
|
||||
let source_x = x + 1;
|
||||
let source_y = yy as i32 - y;
|
||||
let affected = source_x < 3
|
||||
&& (0..3).contains(&source_y)
|
||||
&& mask.pixels[source_y as usize * 3 + source_x] != TRANSPARENT;
|
||||
assert_eq!(
|
||||
canvas.image.pixels[yy * canvas.image.width + x],
|
||||
if affected { 11 } else { 10 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vga_light_tables_clamp_six_bit_channels_and_choose_last_equal_match() {
|
||||
let palette = std::array::from_fn(|index| [(index % 64) as u8; 3]);
|
||||
let identity = legacy_light_table(&palette, 0);
|
||||
let dark = legacy_light_table(&palette, -9);
|
||||
let light = legacy_light_table(&palette, 9);
|
||||
for index in 0..256 {
|
||||
assert_eq!(identity[index], 192 + (index % 64) as u8);
|
||||
assert_eq!(dark[index], 192 + (index % 64).saturating_sub(9) as u8);
|
||||
assert_eq!(light[index], 192 + ((index % 64) + 9).min(63) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vga_remap_uses_manhattan_rgb_distance_instead_of_squared_distance() {
|
||||
let mut palette = [[63; 3]; 256];
|
||||
palette[0] = [20; 3];
|
||||
palette[1] = [14, 10, 10]; // Manhattan 4, squared distance 16.
|
||||
palette[2] = [12, 12, 11]; // Manhattan 5, squared distance 9.
|
||||
assert_eq!(legacy_light_table(&palette, -10)[0], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ground_shadows_keep_native_mask_offsets_and_clip_at_every_scale() {
|
||||
let mask = pattern(7, 9, 31);
|
||||
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
|
||||
for (x, y) in [(3, -4), (-2, 40), (320, 190), (100, 197), (-20, -30)] {
|
||||
let mut native = Canvas::new(1);
|
||||
native.clear(10);
|
||||
// Reference inverse mask coordinates include SHADOW_GAdd's (-3,+4).
|
||||
for yy in 0..200 {
|
||||
for xx in 0..320 {
|
||||
let sx = xx as i32 - x + 3;
|
||||
let sy = yy as i32 - y - 4;
|
||||
if (0..mask.width as i32).contains(&sx)
|
||||
&& (0..mask.height as i32).contains(&sy)
|
||||
&& mask.pixels[sy as usize * mask.width + sx as usize] != TRANSPARENT
|
||||
{
|
||||
native.image.pixels[yy * 320 + xx] = 11;
|
||||
}
|
||||
}
|
||||
}
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let mut actual = Canvas::new(scale);
|
||||
actual.clear(10);
|
||||
shade_ground_shadow(&mut actual, &mask, x as f32, y as f32, &lookup);
|
||||
assert_eq!(actual.image, nearest(&native.image, scale));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reference SHADOW_Draw at native integer coordinates. Find mask runs from
|
||||
// occupied coordinates, project their endpoints, then expand the final native
|
||||
// raster. This keeps the oracle independent of high-resolution span drawing.
|
||||
fn native_air_shadow(mask: &Sprite, x: i32, y: i32, lookup: &[u8; 256]) -> Sprite {
|
||||
let project = |value: i32, center: i32| {
|
||||
let fixed = ((value - center) as i64 * 409_600) / 280;
|
||||
(fixed >> 11) as i32 + center
|
||||
};
|
||||
let mut result = Sprite {
|
||||
width: 320,
|
||||
height: 200,
|
||||
pixels: vec![10; 320 * 200],
|
||||
};
|
||||
let mut seen_rows = BTreeSet::new();
|
||||
for row in 0..mask.height {
|
||||
let occupied: Vec<_> = (0..mask.width)
|
||||
.filter(|&column| mask.pixels[row * mask.width + column] != TRANSPARENT)
|
||||
.collect();
|
||||
if occupied.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let yy = project(y + 20 + row as i32, 100);
|
||||
if !seen_rows.insert(yy) || !(0..200).contains(&yy) {
|
||||
continue;
|
||||
}
|
||||
let mut starts = vec![0];
|
||||
for index in 1..occupied.len() {
|
||||
if occupied[index] != occupied[index - 1] + 1 {
|
||||
starts.push(index);
|
||||
}
|
||||
}
|
||||
starts.push(occupied.len());
|
||||
for endpoints in starts.windows(2) {
|
||||
let start = project(x - 10 + occupied[endpoints[0]] as i32, 160);
|
||||
let end = project(x - 10 + occupied[endpoints[1] - 1] as i32, 160);
|
||||
for xx in start.max(0)..=end.min(319) {
|
||||
let pixel = &mut result.pixels[yy as usize * 320 + xx as usize];
|
||||
*pixel = lookup[*pixel as usize] as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn air_shadows_project_signed_spans_and_suppress_collapsed_rows() {
|
||||
let mask = Sprite {
|
||||
width: 7,
|
||||
height: 7,
|
||||
pixels: vec![1; 49],
|
||||
};
|
||||
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let mut actual = Canvas::new(scale);
|
||||
actual.clear(10);
|
||||
shade_air_shadow(&mut actual, &mask, 170.0, 80.0, &lookup);
|
||||
assert_eq!(
|
||||
actual.image,
|
||||
nearest(&native_air_shadow(&mask, 170, 80, &lookup), scale)
|
||||
);
|
||||
assert_eq!(
|
||||
actual.image.pixels.iter().filter(|&&p| p == 11).count(),
|
||||
25 * scale * scale
|
||||
);
|
||||
assert!(
|
||||
!actual.image.pixels.contains(&12),
|
||||
"collapsed rows must not shade twice"
|
||||
);
|
||||
assert_eq!(
|
||||
actual.image.pixels[100 * scale * actual.image.width + 160 * scale],
|
||||
11
|
||||
);
|
||||
assert_eq!(
|
||||
actual.image.pixels[105 * scale * actual.image.width + 160 * scale],
|
||||
10
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn air_shadows_preserve_sparse_mask_spans_and_clip_projected_edges() {
|
||||
let mask = pattern(21, 17, 76);
|
||||
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
|
||||
for (x, y) in [
|
||||
(12, -48),
|
||||
(-55, 30),
|
||||
(389, 198),
|
||||
(165, 64),
|
||||
(303, 223),
|
||||
(-80, -70),
|
||||
] {
|
||||
let expected = native_air_shadow(&mask, x, y, &lookup);
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let mut actual = Canvas::new(scale);
|
||||
actual.clear(10);
|
||||
shade_air_shadow(&mut actual, &mask, x as f32, y as f32, &lookup);
|
||||
assert_eq!(
|
||||
actual.image,
|
||||
nearest(&expected, scale),
|
||||
"air position ({x},{y}) at{scale}x"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolated_shadows_translate_masks_without_changing_coverage_or_transparency() {
|
||||
let mask = Sprite {
|
||||
width: 7,
|
||||
height: 7,
|
||||
pixels: vec![1; 49],
|
||||
};
|
||||
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
|
||||
let mut integer = Canvas::new(4);
|
||||
integer.clear(10);
|
||||
shade_air_shadow(&mut integer, &mask, 170.0, 80.0, &lookup);
|
||||
let mut half = Canvas::new(4);
|
||||
half.clear(10);
|
||||
shade_air_shadow(&mut half, &mask, 170.5, 80.5, &lookup);
|
||||
assert_eq!(half.image.pixels.iter().filter(|&&p| p == 11).count(), 400);
|
||||
for y in 100 * 4..105 * 4 {
|
||||
for x in 160 * 4..165 * 4 {
|
||||
assert_eq!(
|
||||
integer.image.pixels[y * integer.image.width + x],
|
||||
half.image.pixels[(y + 1) * half.image.width + x + 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut empty = Canvas::new(4);
|
||||
empty.clear(TRANSPARENT);
|
||||
shade_air_shadow(&mut empty, &mask, 170.0, 80.0, &lookup);
|
||||
assert!(empty.image.pixels.iter().all(|&p| p == TRANSPARENT));
|
||||
shade_ground_shadow(&mut empty, &mask, 10.0, 10.0, &lookup);
|
||||
assert!(empty.image.pixels.iter().all(|&p| p == TRANSPARENT));
|
||||
}
|
||||
|
||||
fn png(sprite: &Sprite, palette: &[[u8; 3]; 256], path: &Path) {
|
||||
let mut image = image::RgbaImage::new(sprite.width as u32, sprite.height as u32);
|
||||
for (index, &value) in sprite.pixels.iter().enumerate() {
|
||||
let color = if value == TRANSPARENT {
|
||||
[0, 0, 0, 0]
|
||||
} else {
|
||||
let [r, g, b] = palette[value as usize];
|
||||
[r, g, b, 255]
|
||||
};
|
||||
image.put_pixel(
|
||||
(index % sprite.width) as u32,
|
||||
(index / sprite.width) as u32,
|
||||
image::Rgba(color),
|
||||
);
|
||||
}
|
||||
image.save(path).unwrap();
|
||||
}
|
||||
|
||||
fn sheet(sprites: &[Sprite], columns: usize, palette: &[[u8; 3]; 256], path: &Path) {
|
||||
let width = sprites.iter().map(|s| s.width).max().unwrap() + 12;
|
||||
let height = sprites.iter().map(|s| s.height).max().unwrap() + 12;
|
||||
let rows = sprites.len().div_ceil(columns);
|
||||
let mut canvas = Canvas::new(1);
|
||||
canvas.image = Sprite {
|
||||
width: columns * width,
|
||||
height: rows * height,
|
||||
pixels: vec![0; columns * width * rows * height],
|
||||
};
|
||||
for (index, sprite) in sprites.iter().enumerate() {
|
||||
canvas.blit(
|
||||
sprite,
|
||||
((index % columns) * width + 6) as i32,
|
||||
((index / columns) * height + 6) as i32,
|
||||
);
|
||||
}
|
||||
png(&canvas.image, palette, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires local original palette and sprites; emits private shadow contacts"]
|
||||
fn original_shadow_tables_and_banking_masks_match_indexed_projection() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let catalog = Catalog::open(root.join("data")).unwrap();
|
||||
let palette = catalog.palette().unwrap();
|
||||
let bytes = catalog.named("PALETTE_DAT").unwrap();
|
||||
let vga: [[u8; 3]; 256] = std::array::from_fn(|i| bytes[i * 3..i * 3 + 3].try_into().unwrap());
|
||||
assert_eq!(palette.map(|rgb| rgb.map(|component| component >> 2)), vga);
|
||||
let output = root.join(".reference/visuals");
|
||||
fs::create_dir_all(&output).unwrap();
|
||||
// Independent argmin over (distance, reverse index) preserves GFX_Remap's
|
||||
// last-equal tie rule without the renderer's imperative running minimum.
|
||||
for level in [-9, -6, 9] {
|
||||
let expected: [u8; 256] = std::array::from_fn(|index| {
|
||||
let target = vga[index].map(|component| (component as i32 + level).clamp(0, 63));
|
||||
(0..256)
|
||||
.min_by_key(|&candidate| {
|
||||
let distance: i32 = vga[candidate]
|
||||
.iter()
|
||||
.zip(target)
|
||||
.map(|(&a, b)| (a as i32 - b).abs())
|
||||
.sum();
|
||||
(distance, std::cmp::Reverse(candidate))
|
||||
})
|
||||
.unwrap() as u8
|
||||
});
|
||||
assert_eq!(legacy_light_table(&vga, level), expected);
|
||||
}
|
||||
let air = legacy_light_table(&vga, -6);
|
||||
let ground = legacy_light_table(&vga, -9);
|
||||
let mut banking = vec![];
|
||||
let base = catalog.id("LPLAYER_PIC").unwrap();
|
||||
let terrain_id = Registry::base(&catalog).unwrap().missions[&"base:sector_1_wave_1".into()]
|
||||
.tiles[0]
|
||||
.art
|
||||
.asset
|
||||
.unwrap();
|
||||
let tile = catalog.sprite(terrain_id).unwrap();
|
||||
for frame in 0..7 {
|
||||
let mask = catalog.sprite(base + frame).unwrap();
|
||||
let lookup = std::array::from_fn(|index| index.saturating_add(1).min(255) as u8);
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let mut actual = Canvas::new(scale);
|
||||
actual.clear(10);
|
||||
shade_air_shadow(&mut actual, &mask, 144.0, 160.0, &lookup);
|
||||
assert_eq!(
|
||||
actual.image,
|
||||
nearest(&native_air_shadow(&mask, 144, 160, &lookup), scale)
|
||||
);
|
||||
}
|
||||
let mut preview = Canvas::new(4);
|
||||
for y in (0..200).step_by(tile.height) {
|
||||
for x in (0..320).step_by(tile.width) {
|
||||
preview.blit(&upscale(&tile, 4).unwrap(), (x * 4) as i32, (y * 4) as i32);
|
||||
}
|
||||
}
|
||||
shade_air_shadow(&mut preview, &mask, 144.0, 160.0, &air);
|
||||
preview.blit(&upscale(&mask, 4).unwrap(), 144 * 4, 160 * 4);
|
||||
banking.push(crop(&preview.image, 112 * 4, 132 * 4, 80 * 4, 68 * 4));
|
||||
}
|
||||
sheet(
|
||||
&banking,
|
||||
7,
|
||||
&palette,
|
||||
&output.join("player-shadow-banking-4x.png"),
|
||||
);
|
||||
let mask = catalog.sprite(catalog.id("SHIP37G1_PIC").unwrap()).unwrap();
|
||||
let mut preview = Canvas::new(4);
|
||||
for y in (0..200).step_by(tile.height) {
|
||||
for x in (0..320).step_by(tile.width) {
|
||||
preview.blit(&upscale(&tile, 4).unwrap(), (x * 4) as i32, (y * 4) as i32);
|
||||
}
|
||||
}
|
||||
shade_ground_shadow(&mut preview, &mask, 144.0, 80.0, &ground);
|
||||
preview.blit(&upscale(&mask, 4).unwrap(), 144 * 4, 80 * 4);
|
||||
png(
|
||||
&crop(&preview.image, 128 * 4, 64 * 4, 64 * 4, 64 * 4),
|
||||
&palette,
|
||||
&output.join("ground-shadow-4x.png"),
|
||||
);
|
||||
fs::write(
|
||||
output.join("shadow-tables.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"ground_offset":[-3,4],"ground_level":-9,"air_offset":[-10,20],"air_level":-6,
|
||||
"air_view":[160,100,1000],"air_depth":1280,"distance":200,
|
||||
"ground_table":ground.to_vec(),"air_table":air.to_vec()
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires local original catalog; emits private .reference/visuals contact sheets"]
|
||||
fn original_sequences_preserve_canvases_palette_origins_and_terrain_context() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let catalog = Catalog::open(root.join("data")).unwrap();
|
||||
let palette = catalog.palette().unwrap();
|
||||
let output = root.join(".reference/visuals");
|
||||
fs::create_dir_all(&output).unwrap();
|
||||
let sequences = [
|
||||
("LPLAYER_PIC", 7),
|
||||
("DPLAYER_PIC", 7),
|
||||
("FPLAYER_PIC", 7),
|
||||
("GEXPLO_BLK", 42),
|
||||
("AIRBOOM_PIC", 16),
|
||||
("EXPLO2_BLK", 13),
|
||||
("NMSHOT_BLK", 4),
|
||||
("GUNSTR_BLK", 4),
|
||||
("SHIP22G1_PIC", 6),
|
||||
("SHIP08G1_PIC", 4),
|
||||
];
|
||||
let mut report = vec![];
|
||||
let mut runtime_cache = SpriteCache::new(None);
|
||||
for (name, count) in sequences {
|
||||
let base = catalog.id(name).unwrap();
|
||||
let first = catalog.sprite(base).unwrap();
|
||||
let mut original = vec![];
|
||||
let mut filtered = vec![];
|
||||
let mut runtime = vec![];
|
||||
let mut metadata = vec![];
|
||||
for frame in 0..count {
|
||||
let sprite = catalog.sprite(base + frame).unwrap();
|
||||
assert_eq!(
|
||||
(sprite.width, sprite.height),
|
||||
(first.width, first.height),
|
||||
"{name} frame{frame} shifts animation canvas"
|
||||
);
|
||||
let labels: BTreeSet<_> = sprite.pixels.iter().copied().collect();
|
||||
let mut bounds = vec![];
|
||||
for factor in [1, 2, 3, 4] {
|
||||
let scaled = upscale(&sprite, factor).unwrap();
|
||||
assert_eq!(
|
||||
scaled,
|
||||
oracle(&sprite, factor),
|
||||
"{name} frame{frame} scale{factor}"
|
||||
);
|
||||
assert!(scaled.pixels.iter().all(|value| labels.contains(value)));
|
||||
assert_eq!(
|
||||
(scaled.width, scaled.height),
|
||||
(sprite.width * factor, sprite.height * factor)
|
||||
);
|
||||
let use_nearest = prefers_nearest(base + frame);
|
||||
let presented = runtime_cache.get(&sprite, factor, use_nearest).unwrap();
|
||||
if use_nearest {
|
||||
assert_eq!(*presented, nearest(&sprite, factor));
|
||||
assert_eq!(
|
||||
presented
|
||||
.pixels
|
||||
.iter()
|
||||
.filter(|pixel| **pixel != TRANSPARENT)
|
||||
.count(),
|
||||
sprite
|
||||
.pixels
|
||||
.iter()
|
||||
.filter(|pixel| **pixel != TRANSPARENT)
|
||||
.count()
|
||||
* factor
|
||||
* factor,
|
||||
"{name} frame{frame} loses opacity under nearest fallback"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(*presented, scaled);
|
||||
}
|
||||
if factor == 4 {
|
||||
runtime.push(presented.clone());
|
||||
}
|
||||
// C++ centers registered animations using the full frame canvas,
|
||||
// never its changing opaque bounds. Keep that anchor at every scale.
|
||||
let anchor = (sprite.width as i32 / 2, sprite.height as i32 / 2);
|
||||
let mut canvas = Canvas::new(factor);
|
||||
canvas.clear(37);
|
||||
canvas.blit(
|
||||
&scaled,
|
||||
(160 - anchor.0) * factor as i32,
|
||||
(100 - anchor.1) * factor as i32,
|
||||
);
|
||||
assert_eq!(
|
||||
(160 - anchor.0) * factor as i32 + anchor.0 * factor as i32,
|
||||
160 * factor as i32
|
||||
);
|
||||
let mut minx = scaled.width;
|
||||
let mut miny = scaled.height;
|
||||
let mut maxx = 0;
|
||||
let mut maxy = 0;
|
||||
for (index, &pixel) in scaled.pixels.iter().enumerate() {
|
||||
if pixel != TRANSPARENT {
|
||||
minx = minx.min(index % scaled.width);
|
||||
miny = miny.min(index / scaled.width);
|
||||
maxx = maxx.max(index % scaled.width + 1);
|
||||
maxy = maxy.max(index / scaled.width + 1);
|
||||
}
|
||||
}
|
||||
bounds.push(serde_json::json!({"factor":factor,"bounds":[minx,miny,maxx,maxy],
|
||||
"opaque_pixels":scaled.pixels.iter().filter(|pixel|**pixel!=TRANSPARENT).count()}));
|
||||
if factor == 4 {
|
||||
filtered.push(scaled);
|
||||
}
|
||||
}
|
||||
original.push(nearest(&sprite, 4));
|
||||
metadata.push(serde_json::json!({"frame":frame,"asset":format!("{:05x}",base+frame),"logical_canvas":[sprite.width,sprite.height],"center_anchor":[sprite.width/2,sprite.height/2],"nearest_fallback":prefers_nearest(base+frame),"bounds":bounds}));
|
||||
}
|
||||
sheet(
|
||||
&original,
|
||||
7.min(count as usize),
|
||||
&palette,
|
||||
&output.join(format!("{name}-nearest4.png")),
|
||||
);
|
||||
sheet(
|
||||
&filtered,
|
||||
7.min(count as usize),
|
||||
&palette,
|
||||
&output.join(format!("{name}-scale4.png")),
|
||||
);
|
||||
sheet(
|
||||
&runtime,
|
||||
7.min(count as usize),
|
||||
&palette,
|
||||
&output.join(format!("{name}-runtime4.png")),
|
||||
);
|
||||
report.push(serde_json::json!({"name":name,"frames":metadata}));
|
||||
}
|
||||
let registry = Registry::base(&catalog).unwrap();
|
||||
let mission = ®istry.missions[&"base:sector_1_wave_1".into()];
|
||||
let columns = mission.columns as usize;
|
||||
let rows = mission.layout.len() / columns;
|
||||
let selected = (0..rows - 2)
|
||||
.find(|row| {
|
||||
let labels: BTreeSet<_> = (0..3)
|
||||
.flat_map(|y| (0..3).map(move |x| mission.layout[(row + y) * columns + x + 3]))
|
||||
.collect();
|
||||
labels.len() > 2
|
||||
})
|
||||
.unwrap();
|
||||
let mut joined = Canvas::new(1);
|
||||
joined.image = Sprite {
|
||||
width: 96,
|
||||
height: 96,
|
||||
pixels: vec![0; 96 * 96],
|
||||
};
|
||||
let mut tiles = vec![];
|
||||
for row in 0..3 {
|
||||
for column in 0..3 {
|
||||
let index = mission.layout[(selected + row) * columns + column + 3];
|
||||
let sprite = catalog
|
||||
.sprite(mission.tiles[index].art.asset.unwrap())
|
||||
.unwrap();
|
||||
assert_eq!((sprite.width, sprite.height), (32, 32));
|
||||
joined.blit(&sprite, (column * 32) as i32, (row * 32) as i32);
|
||||
tiles.push(sprite);
|
||||
}
|
||||
}
|
||||
let mut seam_counts = vec![];
|
||||
for factor in [2, 3, 4] {
|
||||
let filtered = upscale(&joined.image, factor).unwrap();
|
||||
assert_eq!(filtered, oracle(&joined.image, factor));
|
||||
let gutter = crop(&joined.image, 30, 30, 36, 36);
|
||||
assert_eq!(
|
||||
crop(
|
||||
&filtered,
|
||||
32 * factor,
|
||||
32 * factor,
|
||||
32 * factor,
|
||||
32 * factor
|
||||
),
|
||||
crop(
|
||||
&upscale(&gutter, factor).unwrap(),
|
||||
2 * factor,
|
||||
2 * factor,
|
||||
32 * factor,
|
||||
32 * factor
|
||||
)
|
||||
);
|
||||
let mut tiled = Canvas::new(1);
|
||||
tiled.image = Sprite::blank(96 * factor, 96 * factor);
|
||||
for (index, tile) in tiles.iter().enumerate() {
|
||||
tiled.blit(
|
||||
&upscale(tile, factor).unwrap(),
|
||||
((index % 3) * 32 * factor) as i32,
|
||||
((index / 3) * 32 * factor) as i32,
|
||||
);
|
||||
}
|
||||
let differences = filtered
|
||||
.pixels
|
||||
.iter()
|
||||
.zip(&tiled.image.pixels)
|
||||
.filter(|(a, b)| a != b)
|
||||
.count();
|
||||
seam_counts
|
||||
.push(serde_json::json!({"factor":factor,"independent_tile_differences":differences}));
|
||||
png(
|
||||
&filtered,
|
||||
&palette,
|
||||
&output.join(format!("terrain-joined-{factor}x.png")),
|
||||
);
|
||||
png(
|
||||
&tiled.image,
|
||||
&palette,
|
||||
&output.join(format!("terrain-independent-{factor}x.png")),
|
||||
);
|
||||
}
|
||||
fs::write(output.join("report.json"),serde_json::to_vec_pretty(&serde_json::json!({"sequences":report,"terrain_source_row":selected,"terrain_differences":seam_counts})).unwrap()).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Opt-in original-catalog campaign inspection harness.
|
||||
//!
|
||||
//! This is coverage, not a gameplay parity proof or an ordinary player victory.
|
||||
//! The fixture owns the original arsenal, fires continuously, aims horizontally
|
||||
//! at visible enemies, cycles specials every 180 steps and uses five mega bombs.
|
||||
//! Before each step, shields are replenished to the original 100+100 maxima.
|
||||
//! No enemies, definitions, damage, spawn timings, RNG or outcomes are modified.
|
||||
//!
|
||||
//! Used by tests/campaigns.rs. To compile as a local reporting CLI, pass the built
|
||||
//! raptor_replica and anyhow rlibs to rustc --extern, with
|
||||
//! -L dependency=target/release/deps. See docs/campaign-validation.md.
|
||||
use raptor_replica::{
|
||||
assets::Catalog,
|
||||
content::{ContentId, Registry},
|
||||
modding::ScriptRuntime,
|
||||
persistence::Pilot,
|
||||
simulation::{Input, Outcome, World},
|
||||
};
|
||||
use std::{collections::BTreeSet, path::Path};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WaveReport {
|
||||
pub mission: ContentId,
|
||||
pub difficulty: u32,
|
||||
pub ticks: u64,
|
||||
pub outcome: Outcome,
|
||||
pub visited_spawns: usize,
|
||||
pub total_spawns: usize,
|
||||
pub enabled_spawns: usize,
|
||||
pub expected_bosses: usize,
|
||||
pub bosses_seen: usize,
|
||||
pub bosses_killed: usize,
|
||||
pub bosses_left_alive: usize,
|
||||
pub shield_replenishments: usize,
|
||||
pub credits: u32,
|
||||
pub digest: String,
|
||||
pub remaining: Vec<String>,
|
||||
}
|
||||
impl WaveReport {
|
||||
pub fn completed(&self) -> bool {
|
||||
self.outcome == Outcome::Complete
|
||||
}
|
||||
pub fn csv(&self) -> String {
|
||||
format!(
|
||||
"{},{},{},{:?},{}/{},{},{},{},{},{},{},{},{},{}",
|
||||
self.mission,
|
||||
self.difficulty,
|
||||
self.ticks,
|
||||
self.outcome,
|
||||
self.visited_spawns,
|
||||
self.total_spawns,
|
||||
self.enabled_spawns,
|
||||
self.expected_bosses,
|
||||
self.bosses_seen,
|
||||
self.bosses_killed,
|
||||
self.bosses_left_alive,
|
||||
self.shield_replenishments,
|
||||
self.credits,
|
||||
self.remaining.len(),
|
||||
self.digest
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn csv_header() -> &'static str {
|
||||
"mission,difficulty,ticks,outcome,visited_spawns,eligible_spawns,expected_bosses,seen_bosses,killed_bosses,left_alive,replenishments,credits,remaining_enemies,state_digest"
|
||||
}
|
||||
|
||||
pub fn catalog_registry(path: &Path) -> anyhow::Result<(Catalog, Registry)> {
|
||||
let catalog = Catalog::open(path)?;
|
||||
let registry = Registry::base(&catalog)?;
|
||||
Ok((catalog, registry))
|
||||
}
|
||||
fn enabled(difficulty: u32, level: u32) -> bool {
|
||||
match level {
|
||||
3 => true,
|
||||
4 => difficulty >= 2,
|
||||
5 => difficulty >= 3,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
pub fn arsenal_pilot(mission: ContentId, difficulty: u32, registry: &Registry) -> Pilot {
|
||||
let mut pilot = Pilot {
|
||||
mission,
|
||||
difficulty,
|
||||
credits: 10000,
|
||||
inventory: Default::default(),
|
||||
..Pilot::default()
|
||||
};
|
||||
for item in registry
|
||||
.items
|
||||
.values()
|
||||
.filter(|i| i.legacy.is_some_and(|id| id <= 17))
|
||||
{
|
||||
let count = match item.legacy.unwrap() {
|
||||
11 => 5,
|
||||
15 | 16 => 100,
|
||||
_ => 1,
|
||||
};
|
||||
pilot
|
||||
.inventory
|
||||
.insert(item.id.clone(), count.min(item.max_count));
|
||||
}
|
||||
pilot.selected = Some("base:forward_laser".into());
|
||||
pilot
|
||||
}
|
||||
pub fn run_wave(
|
||||
registry: &Registry,
|
||||
sector: u32,
|
||||
wave: u32,
|
||||
difficulty: u32,
|
||||
maximum: u64,
|
||||
) -> anyhow::Result<WaveReport> {
|
||||
let mission: ContentId = format!("base:sector_{sector}_wave_{wave}").as_str().into();
|
||||
let pilot = arsenal_pilot(mission.clone(), difficulty, registry);
|
||||
pilot.validate(registry, &[])?;
|
||||
let definition = ®istry.missions[&mission];
|
||||
let eligible: Vec<_> = definition
|
||||
.spawns
|
||||
.iter()
|
||||
.filter(|s| enabled(difficulty, s.difficulty))
|
||||
.collect();
|
||||
let expected_bosses = eligible
|
||||
.iter()
|
||||
.filter(|s| registry.enemies[&s.enemy].boss)
|
||||
.count();
|
||||
let mut world = World::new(&mission, &pilot, registry)?;
|
||||
let mut scripts = ScriptRuntime::new();
|
||||
let mut bosses = BTreeSet::new();
|
||||
let mut killed = BTreeSet::new();
|
||||
let mut left_alive = BTreeSet::new();
|
||||
let mut replenishments = 0;
|
||||
for tick in 0..maximum {
|
||||
if world.outcome != Outcome::Playing {
|
||||
break;
|
||||
}
|
||||
if world.player.shield < 100 || world.player.super_shield < 100 {
|
||||
replenishments += 1;
|
||||
}
|
||||
// Explicit inspection assistance. Do not infer normal-player survival.
|
||||
world.player.shield = 100;
|
||||
world.player.super_shield = 100;
|
||||
let target = world
|
||||
.enemies
|
||||
.iter()
|
||||
.filter(|e| e.health > 0 && e.y + e.height > 0 && e.y < 160)
|
||||
.min_by_key(|e| {
|
||||
(
|
||||
!registry.enemies[&e.definition].boss,
|
||||
(e.x + e.width / 2 - (world.player.x + 16)).abs(),
|
||||
)
|
||||
});
|
||||
let x = target
|
||||
.map_or_else(
|
||||
|| {
|
||||
let phase = (tick % 240) as i32;
|
||||
20 + if phase < 120 {
|
||||
phase * 2
|
||||
} else {
|
||||
(240 - phase) * 2
|
||||
}
|
||||
},
|
||||
|e| e.x + e.width / 2 - 16,
|
||||
)
|
||||
.clamp(5, 282);
|
||||
let prior: Vec<_> = world
|
||||
.enemies
|
||||
.iter()
|
||||
.filter(|e| registry.enemies[&e.definition].boss)
|
||||
.map(|e| (e.entity, e.health))
|
||||
.collect();
|
||||
world.step(
|
||||
Input {
|
||||
absolute: Some([x, 160]),
|
||||
fire: true,
|
||||
cycle: tick % 180 == 0 && tick > 0,
|
||||
bomb: tick % 480 == 240,
|
||||
..Input::default()
|
||||
},
|
||||
registry,
|
||||
&mut scripts,
|
||||
)?;
|
||||
for e in &world.enemies {
|
||||
if registry.enemies[&e.definition].boss {
|
||||
bosses.insert(e.entity);
|
||||
}
|
||||
}
|
||||
for (entity, health) in prior {
|
||||
if !world.enemies.iter().any(|e| e.entity == entity) {
|
||||
if health <= 0 {
|
||||
killed.insert(entity);
|
||||
} else {
|
||||
left_alive.insert(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(WaveReport {
|
||||
mission,
|
||||
difficulty,
|
||||
ticks: world.tick,
|
||||
outcome: world.outcome.clone(),
|
||||
visited_spawns: world.spawn_cursor,
|
||||
total_spawns: definition.spawns.len(),
|
||||
enabled_spawns: eligible.len(),
|
||||
expected_bosses,
|
||||
bosses_seen: bosses.len(),
|
||||
bosses_killed: killed.len(),
|
||||
bosses_left_alive: left_alive.len(),
|
||||
shield_replenishments: replenishments,
|
||||
credits: world.player.credits,
|
||||
digest: world.digest(),
|
||||
remaining: world
|
||||
.enemies
|
||||
.iter()
|
||||
.map(|e| {
|
||||
format!(
|
||||
"{}@{},{} health={} move={},{}-> {},{}",
|
||||
e.definition,
|
||||
e.x,
|
||||
e.y,
|
||||
e.health,
|
||||
e.movement.x,
|
||||
e.movement.y,
|
||||
e.movement.tx,
|
||||
e.movement.ty
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
let root = args
|
||||
.first()
|
||||
.map(|s| Path::new(s))
|
||||
.unwrap_or_else(|| Path::new("data"));
|
||||
let filter = args.get(1).map(|s| s.parse::<u32>()).transpose()?;
|
||||
let (_catalog, registry) = catalog_registry(root)?;
|
||||
println!("{}", csv_header());
|
||||
let mut incomplete = 0;
|
||||
for difficulty in 0..=3 {
|
||||
if filter.is_some_and(|d| d != difficulty) {
|
||||
continue;
|
||||
}
|
||||
for sector in 1..=3 {
|
||||
for wave in 1..=9 {
|
||||
let report = run_wave(®istry, sector, wave, difficulty, 12000)?;
|
||||
println!("{}", report.csv());
|
||||
if !report.completed() {
|
||||
incomplete += 1;
|
||||
eprintln!("Unfinished: {:?}", report);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
anyhow::ensure!(
|
||||
incomplete == 0,
|
||||
"{incomplete} fixture missions did not finish"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user