163 Commits

Author SHA1 Message Date
6c9380ff13 re(flight): a per-frame sampler, and nav oracles that a menu bar cannot break
WIP toward the residual flight-speed-law question (does a 1 s burst reach the
steady angular rate, or is there a per-axis multiplier?). The write-up already
concluded that host-side polling cannot answer it and named a Canary-side hook
as the tool required; that hook now exists (--frame_probe_log, committed as
auto/re-frame-probe in xenia-canary-native) and this is the harness for it.

- `rebuild_canary.sh` -- the surgical rebuild the box can actually do, kept in
  the repo this time instead of in /tmp: compile only the changed objects, `ar`
  them into their archive, and re-run the link command lifted out of the
  generated ninja. A full `ninja` is impossible here (several TUs need dev
  headers the image lacks) and the build cache cannot be re-configured. 31 s.
- `frame_burst.py` -- points the probe at the player craft's transform block
  (pos-112, the three 16-byte-strided rows plus the position) and drives full
  stick holds, recording each hold's start and end in the same clock the probe
  stamps its lines with.
- `frame_session.sh` -- the whole run as ONE blocking foreground call, per the
  session-lifetime rule; REUSE=1 drives a Canary that is already up.
- `nav_to_flight.sh` -- fly_stage.sh's navigation, split out so a live emulator
  can be re-used. A boot to the title costs minutes under lavapipe and a run
  that only failed to NAVIGATE should not pay for it twice.

The navigation change is the one worth reading. Every screen oracle here tested
named pixels ("648,221 is white"), which is only valid while the game image sits
at a known place on the root window -- and it does not: xenia's GTK window has a
menu bar, so on this display the image is ~25 px lower and every constant reads
the wrong row. Nothing errors. One run sat 300 s in front of a plainly visible
MAIN MENU reporting "no main menu"; the next missed the title screen entirely
and let the attract movie loop for ten minutes.

So `screen_id.py` identifies screens by WHOLE-IMAGE statistics instead -- the
fraction of green UI-text pixels, the fraction of near-white pixels, and the
per-channel means -- which no vertical shift, scale or letterbox can move. It is
calibrated against known-good captures and classifies all of them correctly:
title, three different menu screens, in-flight, and four movie frames as
"other". `bin/screenshot` additionally crops the menu bar off saved evidence
shots, deriving the offset from the window's own height rather than a constant.

Not yet a finding: the run has not reached flight, so no rate has been measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 06:03:44 +00:00
4f6fcf36dc re(flight): the ramp test is inconclusive, and names the tool the residual needs
To separate "a 1 s burst never reaches the steady rate" from "a per-axis
multiplier", measure inside ONE hold: successive 0.25 s windows of a single 3 s
press, holding speed, attitude and starting conditions constant by construction.

    rep0 rate   16   59  325  209  130  322  238  110  310  151  183
    rep1 rate   52  181  231  246  236  169  368  195  181  255  182

Not usable. At 0.25 s the windows do not contain enough guest updates to average,
so rate and speed both swing 3x window to window -- the same aliasing that once
manufactured a rate-vs-speed curve, reappearing at finer resolution. The first
window is lowest in BOTH repeats, which is what a ramp would look like, but the
sequence never plateaus, so the signal cannot be separated from the sampling. No
claim either way.

Widening the window does not rescue it: 0.5 s averages well enough, but a hold
long enough to contain several 0.5 s windows bleeds speed -- and speed is the
variable under test. The two effects are entangled at this observation rate.

So the residual needs a different INSTRUMENT, not another script. Live-RAM polling
samples an unsynchronised snapshot; the question wants the craft's angular
velocity as the guest computes it, once per frame. That is a Canary-side hook --
the same shape as the existing F10 ship-capture patch -- and the rebuild toolchain
already makes it cheap. Recorded as the recommendation rather than attempted as a
seventh variation of the same measurement.
2026-08-13 23:53:23 +00:00
8b03a52aa6 re(flight): both axes at settled speeds -- shape confirmed, absolute scale is not
Burst design repeated with the HUD clock bracketed (01:01.86 -> 02:10.61, ~x1.26)
and roll measured in the same flight.

    pitch   87.0 @ 85    82.8 @ 308    47.0 @ 1077   min:max 1.85
    roll   125.0 @ 76   119.0 @ 291    82.6 @ 1023   min:max 1.51

SHAPE CONFIRMED on both axes: the rate interpolates between _Min (at minimum
speed) and _Max (at maximum speed), matching to 1.6% for pitch (vs PitchMinus
75/40 = 1.88) and 5.6% for roll (vs 200/125 = 1.60). Both ratios are
clock-independent so they stand regardless of the conversion, and pitch's
re-confirms ly+ = pitch-MINUS (reproducing 1.82 from the previous run).

ABSOLUTE SCALE DOES NOT MATCH, and not in the same direction:

    pitch  measured/predicted = 1.15, 1.21, 1.07   consistently OVER
    roll                      = 0.62, 0.64, 0.60   consistently UNDER, very flat

A clock error cannot explain this -- it would move both axes together and these
go opposite -- so the discrepancy is per-axis. Candidates, neither measured: a 1 s
burst not completing the angular-acceleration ramp (would under-read; fits roll's
flat 0.62, not pitch's excess), or an unidentified per-axis multiplier. Left as an
open question with the separating test named: 1 s versus 3 s bursts at the same
throttle, where the ramp hypothesis predicts the longer burst reads higher.

Also noted: the roll run correctly REFUSED on a WEAK row pin after the pitch
bursts left the craft tumbled -- and roll is immune to that labelling, which the
guard's own message says, so ALLOW_WEAK_PIN=1 is its documented exception. A guard
that states its own exception is worth having.
2026-08-13 23:42:28 +00:00
92d9683f3a re(flight): short bursts confirm the rate-vs-speed SHAPE, without needing the clock
The design the sweep could not provide: settle the throttle, measure the settled
speed, pitch for ONE second so speed barely moves inside the burst. Three
throttles, two repeats, row pin CONFIDENT, fresh flight.

    LT min      burst speed ~105    rate 113.6, 109.5 deg/wall-s
    cruise                  ~383         100.2,  88.4
    RT max                 ~1483          52.2,  70.5

Rate falls monotonically with speed -- 111.5 -> 94.3 -> 61.4 -- at three KNOWN,
SETTLED speeds instead of smeared across a bleeding one.

The decisive comparison needs no clock. Absolute rates depend on the run's clock
ratio, but the min:max RATIO cancels it:

    measured min:max            = 1.82
    AV_PitchMinus_Min/Max 75/40 = 1.88   ->  3.0% apart
    AV_PitchPlus_Min/Max 150/70 = 2.14   -> 15.1% apart

Two conclusions, neither resting on a clock measurement:
 - _Min/_Max really do mean "at minimum / at maximum speed", with the rate
   interpolating between them: shape confirmed to 3%.
 - ly+ drives pitch-MINUS, not plus. The craft has asymmetric pitch authority
   (75/40 down vs 150/70 up) and the ratio picks the pair cleanly.

Absolute magnitudes remain open: this run did not bracket the HUD clock, so
deg/GAME-second cannot be computed from it, and picking a ratio that makes the
numbers fit would be circular. The probe now screenshots the clock at both ends.

Also: fly_stage.sh now waits for the TAKE-OFF load too. Guarding only the stage
load left a run pressing A into a black screen and then reporting "player entity
not found" from a game that never reached flight.
2026-08-13 23:29:23 +00:00
a3f14710a4 re(flight): clean pitch sweep -- magnitudes agree, the interpolation law does not
Fresh flight, row pinning CONFIDENT (margin 0.413), one sweep and nothing before
it. axis_probe now REFUSES to measure on a WEAK pin (ALLOW_WEAK_PIN=1 overrides)
since it is a precondition, not a warning: roll is immune to the up/right
labelling but pitch and yaw are not.

Clock x1.26. Binned by speed, both in game units, against the linear
interpolation of AV_PitchPlus_Min 150 (at MinimumVelocity 100) to _Max 70 (at
MaximumVelocity 1200):

    speed ~435   measured 100.8   predicted 125.6
    speed ~572            113.8             115.7
    speed ~709            126.3             105.7
    speed ~846             83.1              95.7
    speed ~983             72.7              85.8

Supported: the magnitudes (73-126 measured vs 86-126 predicted) and a falling
high-speed end. NOT supported: the interpolation law. Scatter is +-25%, the two
fastest bins hold 1 and 2 windows (the first moments before the speed bled), and
the slowest bin misses in the wrong direction.

The flaw is structural, not statistical: a sweep DRIVEN by the speed bleeding
cannot dwell at either extreme, which is exactly where the law is most testable.

What would settle it: hold a settled throttle and pitch for ~1 SECOND, so speed
barely moves inside the burst and each burst gives one honest (speed, rate) point;
repeat at LT / neutral / RT for three clean points at known speeds. Recorded as
the next design rather than attempted as a fifth variation of the same sweep.
2026-08-13 23:12:50 +00:00
0a84c1358e re(flight): polling faster than the guest updates manufactures a clean curve
Fitting rate against instantaneous speed produced a tidy "rate rises with speed"
relationship, with speeds up to 4795 when the craft's maximum is 1200. It is
entirely an artefact: 20 Hz polling is faster than the guest updates these fields,
so a per-read delta is either exactly zero (no update yet) or a whole frame's
worth divided by a fraction of a frame. 111 of 352 reads were zero on BOTH
channels -- position and attitude update on the same frame, so the two are
perfectly correlated, and dividing each by the short wall dt produced the
correlation out of nothing.

Fix: aggregate over windows spanning many frames (0.5 s). A sum of |delta| over
such a window is right however the updates fall inside it.

This does NOT affect the swept-total probes (roll_axis.py, rate_probe.py) -- they
already summed over the whole dwell, immune for the same reason. Only per-sample
instantaneous rates were ever wrong, so no earlier number moves.

The windowed re-run is NOT yet claimed as a result. It gives plausible magnitudes
but still shows rate rising with speed, against the definition's PitchPlus_Min 150
> _Max 70, and it has two disqualifiers: it ran on an instance where the craft was
already tumbling from the previous sweep, so pinning reported "WEAK -- craft may
not be level", and the sweep started mid-range rather than at maximum. A clean
answer needs a fresh flight with pinning CONFIDENT and nothing before it. Since
what is in doubt is precisely what _Min/_Max mean, a measurement through a
doubtful instrument cannot settle it.

Both datasets kept, the bad one labelled, because the aliased curve is a good
example of what a manufactured correlation looks like.
2026-08-13 23:00:59 +00:00
6c7025851e re(flight): the rate probe measures a MOVING speed -- pitching bleeds it hard
Measured pitch with the rows properly pinned, against this craft's own disc caps
(AV_PitchPlus_Min 150, AV_PitchPlus_Max 70):

    min speed (LT)  1391.0 deg / 8.00 s, clock x1.326 -> 131.1 deg/game-s  vs 150
    max speed (RT)   989.0 deg / 8.05 s, clock x1.318 ->  93.2 deg/game-s  vs 70

A rate 33% ABOVE a cap is not a finding, it is a broken instrument. The HUD speed
is in the same bracketing screenshots that give the clock, so read it:

    slow phase   102 ->  18
    fast phase  1193 -> 589

The speed is NOT constant during the dwell -- pitching halves it in 8 seconds.
The cap is speed-dependent, so as the craft slowed its cap rose, and an 8-second
average necessarily lands between the max-speed cap and a mid-speed one. The 133%
is entirely the instrument.

This also weakens the roll result committed earlier: same method, so 120.9 vs
AV_Roll_Max 125 is CONSISTENT but is not a tight test -- the true cap could be
lower and still produce that average. Said plainly in the doc rather than left
standing as a clean confirmation. Min-speed figures are less affected; there is
little speed left to lose.

Proper fix, not yet done: dwell ~1-2 s so speed barely moves, or sample HUD speed
continuously and fit rate against INSTANTANEOUS speed -- which yields the whole
rate-vs-speed curve instead of two points.

Separately this is a flight-model finding: TURNING COSTS SPEED, steeply, with the
throttle still at maximum. A reimplementation treating the throttle as a speed the
craft simply holds will be wrong during manoeuvres.
2026-08-13 22:48:29 +00:00
dc46339b63 re(flight): rows pinned by world-Y, and nothing yaws -- the yaw gap is answered
The probe now pins which non-forward row is up and which is right, instead of
taking the D3D convention on faith:

    row world-Y means: [0.469, 0.883, -0.000]   forward = row 2
      -> up = row 1, right = row 0   CONFIDENT

That is the OPPOSITE of the assumption the previous run used, so that run's yaw
and pitch columns were swapped -- under the correct labels its ly+ reading of
154.1 deg/wall-s is PITCH, which is what a left-stick Y should do.

Measured with the unknown inputs FIRST, each passing a liveness check:

    rx  0.0  0.0  0.0      ry  0.0  0.0  0.0
    LB  0.0  0.0  0.0      RB  0.0  0.0  0.0
    lx  roll 209.8         ly  pitch 154.1

These zeros are trustworthy where the previous run's were not: the craft was
verified alive between inputs, and the probe aborted the moment it stopped moving
rather than reporting the clean zeros a destroyed craft produces (it did abort,
after lx+, which is why lx/ly are carried from the earlier run rather than
re-measured).

So NO PAD INPUT YAWS THE CRAFT. AV_Yaw_* (45/25) exists in the definitions but
nothing on the right stick or the shoulders drives it, which upgrades the old
"yaw: no input found" from a failure to find one into a measurement that the
remaining candidates do nothing. The natural reading is that yaw is a consequence
of banking rather than a commanded axis.

Not covered, and not claimed: the d-pad (tactical map) and the face buttons
(fire/weapon select). Neither is a plausible flight axis; neither was measured.
2026-08-13 22:35:36 +00:00
dfa769420d re(flight): axis probe pins the rows and checks liveness; fly_stage waits, not sleeps
Both fixes the previous run's caveats asked for, plus one the run itself forced.

axis_probe.py now:
 - PINS which non-forward row is up and which is right, by comparing world-Y
   across the rows in level flight, and says CONFIDENT or WEAK. entities2
   measures row 2 = forward against velocity, but the other two were labelled by
   the D3D convention, and yaw/pitch SWAP if that is wrong -- so the previous
   run's last two columns were named on an assumption.
 - checks the craft is ALIVE between inputs, and ABORTS with a message instead of
   reporting the clean zeros a destroyed craft produces. The first run ended on
   GAME OVER and only said so afterwards.
 - measures the UNKNOWN inputs (rx, ry, LB, RB) first while the craft is healthy,
   keeping the established lx/ly as controls at the end.

fly_stage.sh now WAITS for the stage load instead of sleeping a fixed guess. The
fixed sleeps worked until they didn't: one load ran long, the script pressed START
into a black screen, and every later step went to nothing while the screenshots
recorded a plausible-looking sequence. It now polls for a non-black frame and
aborts with a pointer to the log if the load hangs (PhysicalHeap::Release
failures) rather than continuing blind.

The probe itself did not run this iteration -- the stage load hung -- so there is
no new axis data, and none is claimed.
2026-08-13 22:22:19 +00:00
5f3c618b51 re(flight): axis probe -- lx is roll, ly drives one clean axis, rest not trustworthy
New probe (axis_probe.py) decomposes every held input into all THREE rotation
components at once, instead of measuring one axis at a time through a non-forward
matrix row -- the flaw that once made roll and pitch produce identical numbers.
For previous rows (f,u,w): roll = atan2(u.w_old, u.u_old), and forward's rotation
toward each of the other two rows gives the remaining pair.

Stage 02, file pad, full deflection on exactly one channel at a time:

    lx+   roll 209.8   b 0.9    c 10.1     deg/wall-s
    ly+   roll   0.0   b 154.1  c  0.0
    rx+   roll   0.0   b 0.0    c  0.0
    ry+   roll 161.1   b 87.0   c 37.1
    LB/RB all zero

What this supports: lx = ROLL, cleanly (~0 on both other channels), agreeing with
the independent roll measurement. ly drives ONE axis, cleanly.

What it does NOT support, and I am not claiming:
 - WHICH axis ly drives. entities2 measures row 2 = forward against velocity, but
   rows 0 and 1 are labelled up/right by the D3D convention rather than by
   evidence, and yaw/pitch SWAP if that is wrong. Roll is immune (rotation of
   either non-forward row in their shared plane is roll either way).
 - anything about rx/ry/LB/RB. The run ended on GAME OVER: full-deflection spin in
   a live combat mission gets the craft destroyed, and the only symptom is "0
   player candidates" AFTERWARDS, so late rows may be post-death. rx+ reading all
   zeros and ry+ reading mixed are exactly what a dying craft would produce.

So "yaw: no input found" is NOT resolved. Both gaps are now written into the
probe's header with what would fix them: a liveness check between inputs, and
pinning up-vs-right against world Y.
2026-08-13 22:10:23 +00:00
850b04c606 re(flight): roll DOES depend on speed -- the withdrawal is reversed
Measured about the FORWARD axis (roll_axis.py), which is the fix the withdrawal
specified: express the new up-vector in the old (up, right) basis and take
atan2(u.w_old, u.u_old), so the component along forward -- what pitch produces --
is dropped by construction. The old probe watched a non-forward matrix row, which
sees any rotation that moves it, and that is why two different stick axes produced
the same numbers.

Stage 02, file pad, single-axis holds (lx=32767 with every other channel exactly
0, trigger held in the same write since the pad state is written whole), 5 s
settles, 8 s dwells, each phase bracketed by HUD-clock screenshots:

  min speed (LT)  1 856.9 deg / 8.01 s wall, clock x1.247 -> 185.9 deg/game-s
                  vs AV_Roll_Min 200
  max speed (RT)  1 181.2 deg / 8.02 s wall, clock x1.218 -> 120.9 deg/game-s
                  vs AV_Roll_Max 125

So roll behaves exactly like pitch: the rate cap falls as speed rises and
_Min/_Max mean "at minimum / at maximum speed". The withdrawn claim that roll
shows NO speed dependence is reversed, and the axis rule is now confirmed on a
second axis instead of contradicted by it.

Both land just under their caps (93% and 97%), the right side for a rate limit.
The shortfall is NOT explained and no claim is made about it: candidates are the
craft not being exactly at min/max speed after 5 s, and the 20 Hz swept-angle sum
undercounting.

The tell that the old result was broken was two conditions agreeing too well. The
tell that this one is sound is that they disagree in the direction the definitions
predict, on two independently bracketed phases.
2026-08-13 21:57:10 +00:00
3d3d6726fa re(flight): START skips the briefing, and the player-entity lock is stage-specific
The scripted route now reaches FLIGHT unattended: boot -> main menu -> poke the
cleared-stage mask -> EXTRAS -> MISSION SELECT -> stage -> briefing -> READY ROOM
-> TAKE OFF -> flight, verified by a full HUD (TIME 00:52.54, speed 350, shields,
REMAINING OB 018) on stage 01.

Two things that cost a run each:

START skips the briefing; A does not. A pages through the brief, and ten A taps
still left the run sitting on a briefing screen -- twice, on two different
stages. One START press lands on the READY ROOM. fly_stage.sh now presses START.

entities2.py's `self` locked on `"Player" in name`, and that suffix is
STAGE-SPECIFIC: stage 02 fields UN_f002_TCAF_DeltaSaber_W_Player, but stage 01
fields UN_f001_TCAF_DeltaSaber_T with no suffix, so the filter found nothing
while the game was visibly flying and reporting 64 typed live entities. Falls
back to the craft class and prefers the instance that is actually moving (a
mission holds more than one). Recorded rather than worked around, because the
same assumption is embedded in several probes.
2026-08-13 21:45:54 +00:00
0b2f004ad0 re(flight): port the roll probe to the file pad, and drive fly_stage.sh to flight
roll_axis.py was written last session to re-measure roll ABOUT THE FORWARD AXIS --
the fix for the withdrawn result, whose defect was the MEASUREMENT (a non-forward
matrix row sees any rotation that moves it, and pitch moves it as much as roll)
and not the input device. It was never run, and it still drove vgamepad, which is
retired for leaking to the host.

Ported to the file pad. The important difference is not the device but the shape:
the file pad is a SNAPSHOT, not independent channels -- each write replaces the
whole state -- so holding a trigger while deflecting a stick has to be one write.
pad_state(**kw) does that. The values are also exact rather than whatever a
virtual stick quantises to, which is what makes a single-axis hold (lx=32767 with
everything else exactly 0) trustworthy for an axis-separation question.

fly_stage.sh now drives the rest of the route it previously stopped short of:
briefing (A: Continue) -> READY ROOM -> up to TAKE OFF -> flight, and notes why
it must get there at all (a snapshot at the briefing yields zero unit-definition
objects; they are instantiated at stage load proper).
2026-08-13 21:37:44 +00:00
faa7b3d611 re(challenge): the part id is never persisted -- differential search says stack only
No literal 26 exists anywhere, so the GamePart id is computed. That does not stop
it being found: the id is KNOWN at each screen from the GamePart table (EXTRAS = 5,
MISSION SELECT = 7), so snapshot both and intersect. New tool diff_words.py does
the classic differential search over the sparse guest image, and find_partslot.sh
drives the two screens and runs it.

Result: 171 MB scanned, exactly 4 addresses read 5 then 7 -- 0x708FFBEC,
0x708FFCBC, 0x708FFDAC, 0x708FFE20 -- and all four are guest STACK (the same run's
log puts thread stacks at 0x709...). So the requested part id exists only as a
stack argument in flight; there is no persistent field, which is consistent with
finding no literal store, and means there is nothing stable to poke.

That closes the last memory-and-menu route to the challenge missions. Reaching
them needs either the genuine in-game unlock (an in-mission attainment, per
AVSCRIPT_COMMAND_ATTAINMENT_CHALLENGE_MISSION_CARGO_SCORE) or an emulator-side
hook that forces the transition -- a code change, not a poke.

diff_words.py is worth keeping well beyond this question: it locates any field
whose address is unknown but whose value is known at two moments.
2026-08-13 21:31:47 +00:00
a79a1da183 re(challenge): withdraw the kind-field constructor attribution -- snapshot refutes it
Section 4 claimed the mission-kind field at obj+144 "is initialised to 0 in the
constructor sub_821783D8, alongside +148 = 0, +132 = 2, +136/+140 = -1". That
linked two code regions on nothing more than both touching +144/+148, and an
in-flight snapshot refutes it:

sub_821783D8 initialises the STATIC at 0x828F3EC0 -- its first act is
InitializeCriticalSection(obj, 256) -- and in the snapshot that object holds
0x000B1C8B at +144 and a float at +592. Not a kind, not flags. So the object that
owns the kind field is UNIDENTIFIED. Scanning the snapshot for it (kind in
{0,3,5,6} at +144, stage 10 at +148, pointers at +0/+52/+604) returns only
matches inside the executable's own static data; the value 10 at +148 is far too
common to discriminate.

The switch itself stands -- it is direct disassembly at two independent sites
(0x82184df0, 0x82185ed0) plus two grouping tests. Only the attribution was wrong,
and the wrong half is the half I inferred rather than read.

Confirmed on screen instead: launching a story stage from MISSION SELECT reaches
a READY ROOM carrying an "EXTRA" watermark -- the EXTRA config section, kind 3,
visible in the UI. Independent evidence the field means what section 4 says even
though its owning object is not pinned.
2026-08-13 21:21:49 +00:00
4cbce6bd21 re(units): S10 closes the story campaign for real -- 69 units, 7 204 values
roster_target had flagged a contradiction: S10, a STORY stage, still fielded an
unharvested unit, which the "story campaign complete at 68 units" claim did not
account for. S10 was simply never flown. Flying it settles it:
UN_e005_ADAN_ElanTypeQ_Margras is in the snapshot, 144 rows, and the merge takes
the file 68 -> 69 units, 9 393 -> 9 537 rows, 7 115 -> 7 204 defaulted-on-disc
values, with 154 disc cross-checks agreeing and 0 disagreeing. roster_target now
reports S10: 0 missing, and every remaining gap is a CHALLENGE stage
(S24/S25/S27/S28/S29).

A much simpler way to fly a story stage, replacing the save-editing route
(tools/re-capture/fly_stage.sh): poke ONE word -- 0x828F40C0 = 0x0001FFFE marks
stages 1-16 cleared -- and MISSION SELECT will launch any of them. Nothing is
written to disc, so there is no save to back up and restore, and TRAP 1 from the
old recipe (launch_mission.sh silently loading the last-used slot) cannot happen.

Route, learned by screenshotting each step: MISSION SELECT -> pick stage -> A ->
mission briefing (A: Continue) -> READY ROOM -> TAKE OFF -> flight. The READY
ROOM carries an "EXTRA" watermark, which is the static analysis's mission-KIND
= 3 ("EXTRA" config section, docs/re/challenge-mission-gate.md section 4) visible
on screen -- an independent confirmation of that field's meaning.

Also recorded: a snapshot taken at the BRIEFING yields 0 runtime objects. Unit
definitions are instantiated at stage load proper, so the snapshot has to wait
for flight; the briefing screen is too early.
2026-08-13 21:14:21 +00:00
aac017dd0d re(challenge): poking unlocks the whole story campaign; MISSION SELECT is story-only
Two results from the running game, one positive and one a clean negative.

POSITIVE: with word A = 0x0001FFFE (stages 1-16) every entry Stage01..Stage16 is
selectable, where the control run had only Stage01 and the rest greyed. Stage16
reads "Lonely Blue Planet - NO RECORD". So any story stage can be launched from
the menu by poking one word, with no save editing at all -- a simpler lever than
the GHAD stage-field patch used until now.

NEGATIVE: with word B = 0x3F (challenge stages 24-29 marked cleared) the list
still saturates at Stage16 -- the cursor stops there and further presses do
nothing. That matches the disc: the debriefing config declares exactly
px_deb_stage01..16, so the list is capped by data, not by the mask. The
challenge missions are NOT reachable through MISSION SELECT, and word B does not
feed it.

Also mapped, without finding the caller: the GP_DIALOG registry (tables.pak #41)
gives DLG_GO_CHALLENGE_MISSION_MENU = 41 and DLG_CHALLENGE_MISSION_AVAILABLE = 42
(0-based, in config order). No raw immediate 41/39/37 appears anywhere in the
GamePart code region, so dialogs are raised through a computed index and the
entry point to GamePart 26 is still unknown. New probe: examples/screen_configs.rs
dumps any tables.pak screen config by substring.
2026-08-13 20:54:57 +00:00
3ef2c438ae re(challenge): MISSION SELECT renders the cleared-stage mask, and a control run
With word A = 2 the screen lists Stage01 SELECTABLE, carrying a High Score and a
Best Time, and Stage02-Stage08 GREYED OUT. One cleared stage, one selectable
entry, at the bit index that names it -- the mask's meaning is now visible on
screen rather than inferred from disassembly.

Two runs, identical navigation, fresh boot each:
  control  word A 0x00000002  -> MISSION SELECT opens normally
  poked    word A 0xFFFFFFFF  -> MmAllocatePhysicalMemoryEx fails on 128 MB,
                                 guest throws, Xenia shows "Disc Read Error"
So last commit's heap failure was caused by the poke, and by a careless one:
0xFFFFFFFF claims stages that do not exist (0, 17, 24-31 in word A). Poking only
real story ids (0x0001FFFE = stages 1-16) does not blow the heap. That the list
screen changes behaviour with the mask is itself evidence word A feeds it.

Getting a trustworthy control took three tries, and every failure produced a
plausible wrong answer rather than an error:
  1. the title-glyph oracle fired during the ATTRACT MOVIE, so A was pressed at
     nothing and the run "reported 0 failures" for a screen it never reached;
  2. Xvfb keeps the previous instance's framebuffer until the new one draws, so
     a screenshot seconds after launch showed the OLD run -- "MAIN MENU reached
     after 1s", against a process that no longer existed;
  3. a single-pixel "is NEW GAME white?" test matched a white LOADING FLASH.
Fixes, all in challenge_probe.sh: blank the root and refuse screen oracles for
the first 40 s; identify a screen by a PATTERN of sampled points (white text AND
the dark panel behind it), not one pixel; require two consecutive samples. The
new oracle was validated offline against all six saved screenshots and accepts
exactly the one real main menu.

General lesson worth the words: a navigation oracle that can only fail by
returning the wrong screen will happily produce a whole run of confident,
meaningless results.
2026-08-13 20:44:27 +00:00
0bc790de52 docs(index): record the file-input pad and the live-write tool 2026-08-13 20:29:18 +00:00
023bb71cfd re(challenge): the cleared-stage mask is CONFIRMED on the running game
Booted the title and read the two gate words live:

    0x828F40C0 = 0x00000002     word A
    0x828F4814 = 0x00000000     word B

Word A = 2 = bit 1. The profile's save is Stage 02 "At Standby" -- stage 01
cleared -- so the mask is exactly one bit, at the index of the one cleared
stage, 1-BASED. Reproduced across two cold boots. That confirms against a known
progress state, on the real game:

  - the singleton is the static object at 0x828F4070, as derived statically;
  - word A is a cleared-stage bitmask (not achievements, not a stage number);
  - bit index = stage id, 1-based, so TimeAttack's REQUIREMENT 16 means "clear
    stage 16" -- the last story mission;
  - word B is the challenge half and is 0 on a story-only profile.

New tools: gpoke.py (live guest-memory WRITE, companion to gmem.py, prints
before/after for every word), pad.py (drives the new --hid=file pad; replaces
vgamepad, which leaked to the host through /dev/uinput), challenge_probe.sh
(one blocking session: boot, wait for title, drive in, poke, screenshot).

Poking both words did NOT surface a challenge entry in EXTRAS -- and that menu
was built 26 s after the poke, so it is not staleness. Entering MISSION SELECT
then failed, but the log names the real cause and it is not the gate:
MmAllocatePhysicalMemoryEx could not satisfy a 128 MB request (parent free
30633/131072 pages), the guest threw a C++ exception, and Xenia surfaced its
generic "Disc Read Error". It is preceded by "BaseHeap::Release failed because
address is not a region start" -- a failed release leaking the range. Recorded
as an emulator heap problem, with the control run (same navigation, no poke)
named as the next step.
2026-08-13 20:28:19 +00:00
f490fecefb re(challenge): both gate words are one ~1880-byte record, and it is not the save
Two results, one of which kills an operational hope I had been carrying.

THE RECORD IS MUCH BIGGER THAN I MEASURED. Reading 0x82175110 to the end: after
the two words, the 8-byte pair, the 184-byte memcpy and the 8 words at +200, it
copies 816 bytes at +232, 816 more at +1048, a sub-object at +1864 and a final
word at +1876. So the record spans +0..~+1880 = singleton +80..+1960, which means
word A is record +0 AND WORD B IS RECORD +1876. That retires last commit's "word
B has no known writer": there is no separate store because the whole record is
copied out, modified and assigned back as a unit (0x8216FF70 -> compare
0x822C3708, assign 0x82170650, then a worker 0x821700A8 -- note 168 decimal, not
hex, which I misread first time -- retrying a commit 0x822C33B8 up to five times).

IT IS NOT THE SAVEGAME, by two independent checks:
 - size: every real save on disk is a 276-byte container deflating to 545 bytes;
   the record is ~1880. It does not fit.
 - files: after many sessions the content tree holds only game0N/savedata and
   game0N/__thumbnail.png per slot plus three Headers/*.header. No second data
   file exists anywhere under the title id.
Also negative: the savegame object is *(*(this+4)) + 304, and the singleton's
holder address 0x828F48B0 is referenced NOWHERE outside the accessor, so this+4
is a different holder -- the save block is not a window into this record.

=> Hand-editing a save cannot unlock the challenge missions. The earlier
savegame-editing win does not extend here.

WHAT WOULD WORK. The singleton is a static object at 0x828F4070 (0x8216F650:
addis 0x828F + addi 16496), so the gate words sit at fixed guest addresses with
no scanning: word A = 0x828F40C0, word B = 0x828F4814. Canary maps guest RAM into
/dev/shm and the project already reads it live, so writing 0xFFFF / 0x3F there
while the title sits on a menu should open all six challenge missions without
playing the campaign -- the route to the last 42 EX units. Untested; needs a run.
2026-08-13 20:06:11 +00:00
20299c05a1 re(challenge): the gate is CLEARED STAGES -- my achievement reading was wrong
Third revision of this claim, and this one has the writer.

Word A (singleton +80) has exactly ONE writer in the image. Scanning all 22
callers of the +80 struct copier (0x82175110) for one that stores to the copy's
word 0 gives a single hit, 0x821C1820, inside GamePart_StageClear:

    if (this+1004 & 0x20000) skip           ; already recorded
    copy local = singleton->progress        ; src = obj+80
    local.word0 |= 1 << (this+84)
    if changed: singleton->set(local)       ; assign + async persist

and this+84 is the STAGE NUMBER, by two independent uses: 0x821C1760 indexes a
20-byte per-stage record array with it (this + (x+7)*20, three words plus an
8-byte timestamp -- the savegame's 16x20 SHAB shape), and 0x821C1EEC/0x821C1924
pass it as the index into the config key "STAGE" (0x820A2540), the debriefing's
px_deb_stage01..16 sprite list.

So a challenge mission's REQUIREMENT n means "stage n has been cleared", and the
<24 / >=24 split is the disc's own stage numbering: story 1-16 and tutorial 18-23
in word A, challenge 24-29 as word B bits 0-5. TimeAttack needs stage 16 -- the
last story mission -- and the other five chain off challenge stages 25-29. That
is what the raw numbers suggested in the first place.

REFUTED: "the bit space is the game's 24 achievements". ACHIEVEMENTS_REQUIREMENTS
having 24 entries and the gate splitting at 24 is a COINCIDENCE -- 24 is also the
first challenge stage's id. Nothing copies the Debriefing's masks (+208/+736/+740)
into +80, which is the check that should have preceded the claim.

The achievement work itself stands and is kept, retitled and rescoped in
structures/achievements.md: the XACH table (.pe 0x8FBCBC, 36-byte records,
1000G self-check), the 24 names/descriptions, the on-disc ACHIEVEMENTS_REQUIREMENTS
list, and the XACHIEVEMENT_DETAILS/XAM read path. It just does not gate the
challenge missions.

Still open: word B (+1956) has no known writer -- GamePart_StageClear's
unconditional 1 << x would land a challenge stage on word A bits 24-29, not word
B, so clearing a challenge mission must be recorded by another path (presumably
the one that also stores its Time/Points record).
2026-08-13 19:54:39 +00:00
90e0e66c7b re(achievements): earned state comes from XAM, and my bit numbering was wrong
Two findings, one of them a correction to the previous commit.

FOUND: GamePart_Debriefing enumerates XACHIEVEMENT_DETAILS from XAM
(0x8218F888). The records are 36 bytes -- confirmed by the arithmetic, not by
eye: the count is a byte count divided by 36 through the 0x38E38E39 multiply-high
magic plus srawi 3. Field +0 is used as a shift amount, field +32 is tested for
0x00020000, and the buffer sits behind a handle that is waited on (0x824AA330
with -1) then closed (0x824AA3E0). That is the XDK struct exactly, with
XACHIEVEMENT_DETAILS_ACHIEVED == 0x20000, via the
XamUserCreateAchievementEnumerator / XEnumerate pattern.

So the title does NOT persist earned achievements itself -- it asks the console.
The lever for achievement-gated content is the emulator's PROFILE data, not the
savegame. Independently, the singleton the challenge gate reads is not the object
holding the save block: re-scanning all 202 accessor call sites WITH function-
boundary stops touches only +80/+1956/+1960/+1964 and none of the known save
offsets (+304/+316/+320/+336/+380/+440). The earlier scan that seemed to find
them had register tracking bleeding into the next function -- a false positive I
am recording rather than quietly dropping.

CORRECTED: the previous commit claimed "bit n <-> achievement n+1" and treated
the Debriefing award pass and the challenge gate as the same bit space, both as
confirmed. Neither holds up:

- the only place the image is observed turning an achievement into a bit does
  1 << dwId with 1-based ids, so bit = the id and bit 0 is unused;
- the "list index is the bit index" step assumed 0x82448338's out-parameter is
  the loop ordinal. It is the child entry's first word out of a 12-byte array,
  and whether that is an ordinal, an id or a name hash is not pinned;
- the Debriefing's masks are its own fields (+208/+736/+740). Nothing observed
  copies them into the singleton's +80, and no writer of that bitmask has been
  found at all.

The requirement-type/id agreement still stands, but it confirms the LIST ORDER,
not the bit numbering. Consequence: if the join holds, TimeAttack's REQUIREMENT
16 is achievement 16 (Night Ravens Patch), not 17 (Solar System Defense Award).
Both read plausibly as a first-challenge gate, which is exactly why it needs
evidence and not the better story.

Also noted: +1960 is a third bitfield on the same singleton, and a what-changed
pass at 0x8219F3A4 diffs it and reports each newly set bit as bit + 64 -- so
there is a wider flag-id space whose bases are not yet worked out.
2026-08-13 19:43:35 +00:00
33ae20896e re(challenge): the gate's bit space is the game's 24 ACHIEVEMENTS
Static only. Last commit left "REQUIREMENT is a bit index into a progress
bitfield" with the space unidentified. It is the achievement space, and both
halves are now readable off the disc and the executable.

- GamePart_Debriefing (0x8218CF38-0x82191B18) awards them: sub_8218F9A8 walks
  the on-disc ACHIEVEMENTS_REQUIREMENTS list (tables.pak #16, schema 744c0519),
  and for entry index n tests bit n, evaluates the entry when clear, and sets
  the bit when satisfied. The list is literally ACHIEVEMENT01..ACHIEVEMENT24 --
  24 entries, which is exactly where the challenge gate splits word A from
  word B.

- The XEX carries the definitions: XACH at .pe 0x8FBCBC, 36-byte records
  {id, name_id, unlocked_desc_id, locked_desc_id, image_id u32, gamerscore u16,
  pad, flags u32, 16 zero bytes}, strings from one XSTR per language (English is
  table #5). tools/xach_dump.py parses it. SELF-CHECK: the 24 gamerscores sum to
  exactly 1000, the retail total -- a wrong stride does not land on a round 1000.

- The two sources agree on ORDER independently: the requirement types
  ShootDownAircrafts 1000/10000, ShootDownShips 100, ShootDownWeight MegaTons,
  GetAllWeapons and GetAllAchievements line up with ids 19-24 exactly as XACH
  names them. So bit n <-> achievement n+1 is evidence, not inference. (Those
  last two are requirement TYPES, not debug cheats, despite how they read.)

- Corollary: TimeAttack's REQUIREMENT 16 -- the one value that sits in direct
  value-before-key adjacency, so it survives IDXD dedup -- is bit 16 =
  achievement 17, "Solar System Defense Award", i.e. finish the story campaign.
  The other five values (25-29) are >= 24 and so index word B, a second flag
  space, plausibly a challenge-clear chain. Still 🟡.

REFUTED, from the last commit: the stores to +1956 in 0x822AF278 / sub_822C8748
are NOT this singleton. That object comes from 0x822CEB30, checks a +2652 flag
and stores string POINTERS at +1956/+2024 -- and a pointer ANDed with 1<<n is
meaningless as a gate. So nothing in the image writes this singleton's +1956
field-wise, and where the mask persists (save vs Xbox profile) is open. XEX
imports are by ordinal, so absent XamUser* strings are not evidence either way.
2026-08-13 19:32:43 +00:00
8ecd70f1bc re(challenge): the unlock is a bit test, and the mission table is on disc
Static only. Bounding each GamePart's code block by its factory creator thunk
(id -> creator recovered for 22 of 24 registrations at 0x8280C000-0x8280F800)
puts GamePart_ChallengeMission at 0x82187E60-0x8218CF10. Resolving every string
that block references gives the screen's config schema, and the record itself is
on disc -- tables.pak schema 54a10697, one copy per language, English entry #64.

Six missions: TimeAttack (record Time), ScoreAttack (record Points) and
Extra01..Extra04, each with MISSION_ID / REQUIREMENT / REQUIREMENT_DESC /
THUMBNAIL / STAGE_DESC / NEW_STAGE and a NORMAL_BUTTON / GRAY_BUTTON pair -- so
the screen always lists all six and greys out what is not earned.

THE GATE (0x82189970-0x821899D8), read off the code:

  REQUIREMENT absent      -> available
  REQUIREMENT == "Always" -> available
  else n = atoi(REQUIREMENT)
      n == 0              -> locked
      n <  24             -> test bit n      of the word at singleton+80
      n >= 24             -> test bit (n-24) of the word at singleton+1956

The singleton is 0x821707C0 (lazy, global 0x828F48BC). So availability is one
bit in a progress bitfield and REQUIREMENT is a bit INDEX -- not a stage number,
score or difficulty.

Values per mission are 🟡: the pool's numeric tokens are 16/25/26/27/29 and
24/28 already appear earlier as font metrics, so they would be deduped -- which
fits 24..29 but IDXD dedup makes positional pairing unsound here, so it is
recorded as a hypothesis, not a table.

Negative: the requirement TEXT is not in GP_CHALLENGE.pak (TextIndex over it =
0 entries; its only prose is embedded font copyright). Its PATH is a per-language
branch the loader does not currently reproduce.

Next: three stores to +1956 sit in 0x822C7DD0 / 0x822C8748, the same region as
the save serializer 0x822C00E8 -- if the bits are save-backed, a hand-written
save unlocks all six challenge missions and the last 42 units become one run.
2026-08-13 19:14:48 +00:00
10a96844ba re(challenge): the EX missions are a MODE, not a stage number
Static-only (no emulator, no pad input). Three findings, each with its own
evidence:

- The disc holds exactly 29 StageResource records in three families --
  S01-S16 story, S18-S23 tutorial (all bg=Original), S24-S29 challenge, plus
  Test. That is 16 + 6 + 6 + 1, matching weapon.tbl's stage01..16 /
  tutorial01..06 / challenge01..06 key set exactly. S17 does not exist.
  GP_CHALLENGE.pak has 0 IDXD objects -- it is the menu screen; challenge
  missions reuse GP_MAIN_GAME_E.pak's records.

- The GamePart id table is at 0x820A1630 (29 ids). Indices are confirmed by
  the image's own RegisterToFactory<N, class silph::GamePart_*> text, not by
  position: GP_CHALLENGE = 26, GP_TUTORIAL = 25, GP_BUNK = 10.

- The stage loader selects its config section from a mission-KIND field at
  object+144: 3 -> EXTRA, 5|6 -> CHALLENGE, else FILE (two independent sites,
  0x82184df0 and 0x82185ed0; two more classify {3,5,6} as one group). The
  constructor sets it to 0 and every write inside the class only clears it,
  and no immediate 3/5/6 store to it exists image-wide -- so the kind is
  supplied by the launching GamePart, never derived from the stage number.

That last point is a mechanism (unproven) for why patching the save's stage
field to 27 kills the load: the record is a challenge stage but the kind stays
FILE. Names an untried, zero-cost discriminator -- try stage 18-23.

Also flagged, not resolved: roster_target says S10 (a STORY stage) still
fields an unharvested unit, which contradicts the "story campaign complete"
claim by one unit.
2026-08-13 18:59:52 +00:00
eac92c3e44 re(flight): withdraw the roll result — the probe was measuring pitch as much as roll
Re-running roll with 5 s settles and in-run clock brackets:

  this run's clock: TIME 00:34.93 -> 00:45.97 = 11.04 s game in 7.98 s wall = 1.383
  min speed: 90.6 deg/wall-s /1.383 -> 65.5 deg/game-s  (AV_Roll_Min 200)
  max speed: 59.1            /1.383 -> 42.7             (AV_Roll_Max 125)

The corrected numbers are within a few per cent of the PITCH run's 67.8 and 40.9 —
two different stick axes cannot produce the same rates, so the probe is not
separating them. Cause: watching a non-forward matrix row sees any rotation that
moves that row, and pitch moves it as much as roll. The correct measure is rotation
ABOUT the forward axis (project the row onto the plane perpendicular to forward and
track that angle).

So "roll shows no speed dependence, unlike pitch" is withdrawn: it rested on 2 s
settles and a row that mixes axes, and the two runs disagree with each other
(144/150 then, 90.6/59.1 now). AV_Roll_{Min,Max} are not confirmed and the axis
question is open.

The clock ratio is now measured three times in three flights: 1.260, 1.311, 1.383 —
a property of the moment, not the machine, so every rate probe must bracket its own
phases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 18:12:20 +00:00
8b10c0451f re(flight): the clock factor is universal — angular matches too once corrected in-run
pitch_gametime.py brackets each turn phase with HUD screenshots, so the mission
clock's own advance converts wall seconds to game seconds within the same run:

  this run's clock: TIME 00:33.68 -> 00:44.12 = 10.44 s game in 7.96 s wall = 1.311

  pitch @ min speed  88.9 deg/wall-s  /1.311 -> 67.8 deg/game-s  vs AV_PitchMinus_Min 75
  pitch @ max speed  53.6            /1.311 -> 40.9             vs AV_PitchMinus_Max 40

Both land on the definition (the slow phase 10% low, consistent with including the
AA_* ramp in an 8 s window), so the clock explanation covers angular motion as well:
every stated rate is per GAME second.

The ratio is not a machine constant — 1.260 in the earlier flight, 1.311 here — so it
must be measured in the same run as whatever it corrects. Bonus: the same shots show
the HUD reading 102 at full LT against MinimumVelocity 100.

Also documents the trap that cost three runs: a killed Canary leaves both its shm
image and its last frame on screen, so a dead emulator looks alive and the scans
report "0 moving triples" like a tooling bug. pgrep -x matches zombies, so
speed_law.require_live_emulator() checks the process state letter and refuses to
measure a corpse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 17:56:16 +00:00
ae5f322c03 re(flight): the linear factor is the CLOCK — 1.260 measured against 1.267
Cleanest linear measurement: neutral throttle (HUD = CruisingVelocity 350), sticks
centred, 20 s of perfectly straight flight (displacement/path = 1.000):

  8 900 world units in 20.1 s -> 443.6 /s -> 1.267x the HUD's 350

And the game's own mission timer across a wall-clock interval:

  TIME 00:08.79 -> 00:46.97 = 38.18 s of game time in 30.29 s wall = 1.260

Same number. So the linear discrepancy is not a unit difference: the mission clock
runs ~1.26x faster than wall time under this emulator, and dividing world
displacement by WALL seconds inflates speed by exactly that. World units and
displayed speed share one unit; the definition velocities are per GAME second.

This supersedes the previous "world-unit vs displayed-speed" reading.

Left open (): settled turn rates measured 74.9/41.1 deg/s in wall time against
AV_PitchMinus_Min/Max 75/40, but the clock argument predicts ~94 for the first.
Either that agreement was luck inside a noisy sample (per-window rates spanned
61-96) or angular integration is frame-based where linear is time-based. The check
is to re-measure pitch and convert wall->game seconds with the clock ratio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 17:26:27 +00:00
0cb81b6200 re(flight): the linear discrepancy is a world-unit vs displayed-speed difference
Screenshotting the HUD speed readout at each throttle step, beside the
position-derived measurement of the same moment:

  RT 0.00   HUD 350 (= CruisingVelocity)   position ~447   ratio 1.28
  RT 0.25   HUD 507                        position ~652   ratio 1.29
  RT 0.75   HUD 963                        position ~1141  ratio 1.19

So (a) the HUD speaks the definition's units — exactly CruisingVelocity at neutral,
963 at three-quarters against the 987 the interpolation predicts — confirming the
throttle law in the game's own numbers without any position sampling; and (b) world
displacement runs ~1.2x the displayed speed. Since settled angular rates need no such
factor, this is a unit difference between the position triple and the velocity
fields, not a clock effect: a reimplementation moving entities at MaximumVelocity in
world coordinates will be ~20% slow.

Also fixes speed_law.find_player: a mission holds more than one *_Player object and
at least one never moves, so the finder now samples each candidate twice and keeps
the one that displaces. Locking onto the static one is what produced a run of exact
zeros while the game was visibly flying.

🟡 The ratio is 1.19-1.29 rather than a clean constant and every sample was taken in
a firefight; pinning it wants a quiet map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 17:06:35 +00:00
f6974ff3f0 re(flight): settled turn rates match the definition exactly — withdraw the time-base claim
Re-measured with 5 s of settle per phase and the speed recorded at the moment the
turn starts (flight_law3.py):

  pitch @ 130/s    74.9 deg/s   vs AV_PitchMinus_Min 75
  pitch @ 1821/s   41.1         vs AV_PitchMinus_Max 40
  roll  @ 110/s   129.5         vs AV_Roll_Min 200
  roll  @ 1722/s  149.3         vs AV_Roll_Max 125

Pitch lands on the definition's own numbers with NO scale factor, so the ~1.2x I
attributed to the emulated time base two iterations ago was an artefact of
differentiating during the AA_* acceleration ramp with too little settle. That
explanation is withdrawn: AV_* can be used verbatim.

What remains is only on the linear side — settled speeds still read high and vary
between runs (RT full: 1342 in one flight, 1821 in another, vs MaximumVelocity 1200),
consistent with a craft being shoved around in a firefight. The HUD reads exactly
CruisingVelocity at neutral. A clean linear measurement needs a quiet map; no cause
is claimed until then.

Roll re-measured with proper settles confirms the axis difference: no speed
dependence, both regimes near AV_Roll_Max, where pitch moved 75 -> 40.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 16:46:33 +00:00
6ebbbeff65 re(flight): LT mirrors RT, and roll does not depend on speed
One flight, two measurements (flight_law2.py, binding early so the moving-craft scan
can see the player).

LT curve: 436, 379, 289, 209, 126 units/s across LT 0.00 -> 1.00 — a straight ramp,
whose endpoints after the ~1.2 time-base factor are CruisingVelocity 350 and
MinimumVelocity 100. So the law is symmetric:

  RT: target = Cruising + RT * (Maximum - Cruising)
  LT: target = Cruising - LT * (Cruising - Minimum)

Roll (measured on a non-forward matrix row, since roll turns about the forward axis):
~144 deg/s at minimum speed and ~150 at maximum — no speed dependence, where pitch
dropped by a third to a half between the same regimes. After the time-base factor
that is ~121, i.e. AV_Roll_Max 125 in BOTH regimes.

So _Min/_Max does not mean the same thing for every axis: pitch interpolates with
speed, roll appears pinned at Max. A reimplementation applying one rule to all axes
would get low-speed roll wrong by ~60%.

Caveat recorded: the two roll phases were 2 s of settling apart, marginal for a
126 -> 1342 speed change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 16:30:19 +00:00
4dbd4f6cef re(flight): the throttle is analogue — target speed interpolates cruise -> maximum
RT is an analogue trigger, so "held" was one point on a curve. Walking it 0.00 ->
1.00 (throttle_curve.py) gives a straight ramp: 438, 626, 879, 1094, 1342 units/s.
Dividing by the ~1.2 time-base factor, the endpoints land on the definition's own
numbers (365 vs CruisingVelocity 350; 1118 vs MaximumVelocity 1200) and the midpoint
follows, so

  target speed = CruisingVelocity + RT * (MaximumVelocity - CruisingVelocity)

which refines the earlier "selects one of three targets" reading: those three are the
curve's endpoints.

It also refutes the standing afterburner hypothesis that full RT is the burner: the
curve is smooth through full deflection with no step, and the shield does not move.

The LT half is not measured yet — the entity scan needs the craft moving when it
runs, so a mission left idling drops out of it. Bind early.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 16:14:47 +00:00
fb1405b13f re(flight): the afterburner is not on A/B/X/LB/LS/RS — bounded negative, with a cheap HUD oracle
The definition describes the burner (AB_ConsumeShield_Begin 50, AB_ConsumeShield 10,
AB_AV_* turn caps well below normal) but names no input, and carries no AB velocity
field.

Three probes, all negative for A, B, X, LB (plus LS/RS on the first):
- ab_probe.py: hold RT for a max-speed baseline, then each candidate — speed stayed
  inside the baseline's own noise band every time.
- ab_state_probe.py: sample a window of the player object during each hold and
  report any float that falls — nothing fell.
- HUD oracle needing no offsets: count green pixels of the SHIELD bar on a freshly
  spawned craft. AB_ConsumeShield_Begin 50 should take a visible bite; the bar read
  156/156/156/157/157 across baseline and all four buttons.

So the burner needs a chord, an input this pad cannot reach, or belongs to another
craft/the AI. Recorded so the obvious buttons are not re-probed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 15:51:32 +00:00
86fcfcc8b0 re(flight): turn rates confirm AV_* are rate caps and _Min/_Max mean at min/max speed
turn_law.py pins the speed regime with a throttle, holds a stick axis and
differentiates the craft's own forward vector over 1-second windows.

  slow + nose down  ~87 deg/s   (AV_PitchMinus_Min 75)
  fast + nose down  ~54         (AV_PitchMinus_Max 40)
  slow + nose up   ~175         (AV_PitchPlus_Min 150)
  fast + nose up   ~136         (AV_PitchPlus_Max 70)

So agility falls with speed (_Min/_Max are at minimum/maximum speed, not rate
bounds) and pitching up is ~2x pitching down, exactly as the field pairs say.

Control mapping measured: LX is roll (forward vector barely moves, 3-5 deg/s), LY is
pitch (+1 = nose down per vgamepad's LY: -1 = up), and the right stick does not steer
at all.

The ~1.2x overshoot seen in the speed law appears again here (1.16-1.35x), and a
unit scale cannot explain both m/s and deg/s — a TIME BASE can: if the guest's
simulated second is shorter than the wall-clock second the probe measures against,
every rate reads high by the same factor. So the definition numbers are
self-consistent and these measurements confirm the shape of the law, not a scale.

Recorded 🟡: no yaw input found (AV_Yaw_* exists but neither stick yaws), which with
roll on LX and MaximumBank_Normal points at a bank-to-turn model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 15:30:00 +00:00
1efc3f567d re(flight): the throttle is a target-speed selector, measured against the definition
speed_law.py locks onto the player entity once and samples its position while
holding each throttle input, differentiating over 1-second windows.

  no throttle  -> ~420   (CruisingVelocity 350)
  RT held      -> ~1 530 (MaximumVelocity 1200)
  LT held      -> ~125   (MinimumVelocity 100)
  release      -> back to cruise, from either direction

So the throttle SELECTS a target speed rather than adding thrust — which is what a
reimplementation would most likely have assumed from Acceleration/Deceleration
alone. Those govern the convergence rate instead: ~440 units/s^2 measured on
release (Deceleration 500) and ~470-560 under RT (Acceleration 600).

Recorded as 🟡: measured world speeds run ~1.2-1.3x the definition numbers in all
three regimes while the HUD shows the definition value exactly (350 at cruise), so
world coordinates are a constant multiple (~1.25) of the definition's velocity unit;
the spread is wider than the constant is precise because the craft manoeuvres while
sampled.

Three traps documented: RT/LT are analogue triggers (the button verb is a silent
no-op and the first run measured an unflown craft), per-sample differentiation
aliases against the guest's update rate (0, 1519, 1985, 0, 2681 for smooth flight),
and the player entity only enters the typed scan ~15 s in while the craft dies within
minutes if nobody flies it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 15:08:11 +00:00
6d0bdf0179 re(units): S08 closes the story stages (68 units), and GAME_CLEAR unlocks nothing
S08 gave the last two story-stage units — UN_be005_ADAN_SpaceFortress and
UN_mn500_ADAN_FloatingMine — plus six S08 asteroid collision meshes: 68 units,
9 393 rows, 7 115 defaulted-on-disc values. Every unit any story mission fields is
now read (689 more disc cross-checks, 0 disagreements).

Probe recorded as a negative: setting Game Status (GHAD +48 and its header mirror
+0x18) to 2 = STATE_GAME_CLEAR leaves both menus unchanged — the title still shows
NEW GAME / LOAD GAME / TUTORIAL / OPTIONS / EXTRAS and EXTRAS still shows only
MISSION SELECT / MOVIE THEATER / BACK. So the EX/challenge rosters are not gated on
that field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 14:39:05 +00:00
0221217968 re(units): six targeted harvests — 60 of 110 units, 6 071 defaulted values
S03, S06, S09, S13, S15, S16 (plus the earlier run): 21 -> 60 units,
963 -> 6 071 defaulted-on-disc values, 8 241 rows. Disc cross-checks agree with
0 disagreements throughout.

- UN_e901_ADAN_Boss and UN_e910_core_ADAN_GeneratorCore are harvested (HP 10 000,
  Size_X 500, Size_Radius 250, MaximumVelocity 450) WITHOUT the boss ever being on
  screen: definitions are instantiated at stage load, so a unit only needs to be in
  the roster. That is far weaker than what the draw-capture work requires.
- The extra stages are not reachable via the save's stage field: stage 27 boots to
  the title and then the emulator exits during the load, as do the other S24..S29
  entries, while story stages 1-16 all load. The field addresses the story campaign
  only; EX/challenge rosters need the Challenge-mode menu path.

Remaining 50 units are almost all EX variants in S24/S25/S27/S28/S29; the only
story-stage stragglers are UN_mn500_ADAN_FloatingMine and
UN_be005_ADAN_SpaceFortress in S08.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 14:14:18 +00:00
bf6825e278 re(units): target missions by roster, harvest S09 — 36 units / 3 439 defaulted values
examples/roster_target.rs ranks stages by how many roster units are still
unharvested. The EnumUnit_S<NN> tables are found by hashing candidate TOC paths
(hash::TOC_NAME_SCHEMES) — UnitRoster::stage can only infer a tag when the roster
carries a UN_S<NN>_ prop, which most do not.

It picked S09 (10 missing). Flying it: 26 -> 36 units, 3 345 -> 4 785 rows,
2 351 -> 3 439 defaulted-on-disc values. New: e102_Battleship, e104_Carrier,
e107_AAFrigate, e011_Attacker_B, e008_TurretPlus, be001_TerrafoamingUnit,
e001_Elan_GR{,_Violeta}, f102_LightCarrier_Inv, f106_Destroyer_Inv.

Also settled: the definition objects are mission-independent. Eleven units appear in
more than one snapshot and four are not byte-identical, but compared through the
layout ZERO mapped fields differ — the 12 differing slots are all unmapped (offsets
4/8/16/20 and 0x250/0x268/0x300-0x308/0x330-0x338: object header and sub-object
pointers). So a harvested value is the definition, not a per-mission tweak, and the
earlier UN_f201_TCAF_Tanker flag resolves the same way. Cross-checks over three
snapshots: 1 052 agree, 0 disagree.

Third angle field found the same way (Through_AngleMaximum = 60 degrees in radians),
so the degrees<->radians rule covers any name containing "Angle".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 13:48:12 +00:00
14d5ed54d1 re(units): read definitions with the code-derived layout — defaulted values 1 059 -> 2 351
unit_runtime.py can only place fields the disc values (it scores triples against
disc records): 58 of 153 from one snapshot. data/unit_definition_layout.txt came
from the title's loader instead, so it places all 159 — including the fields no
disc record sets, which is the Route-B target.

tools/re-capture/unit_dump_layout.py reads every field of every live definition
object with that layout and keeps the discipline: a field the disc DOES value is a
check, not a new value. Over two snapshots (19 objects): 700 cross-checks agree,
0 disagree.

  fields placed per object            58 of 153 -> 159
  rows over those 19 units                  609 -> 2 736
  defaulted-on-disc values (whole file)   1 059 -> 2 351

Two traps recorded: the layout table's offsets are DECIMAL while the solver CSV
prints hex (parsing as hex fails the cross-check on everything — which is how it
announced itself), and angle fields can carry a prefix (AB_AA_PitchPlus is still an
angle, so the AV_/AA_ test must match anywhere in the name).

Flagged: UN_f201_TCAF_Tanker's object is not byte-identical between the two
missions — per-mission override or a runtime-mutated field; needs a third snapshot
to separate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 13:30:49 +00:00
d61baddaf0 re(units): stage select lifts the per-stage coverage limit — 21 -> 26 units
Unit definitions are instantiated per stage, so the parked "coverage grows by
visiting missions" limit was blocked on progress. With the save's stage field
solved, any story stage can be flown from a hand-edited save and snapshotted.

First harvest (a Night Ravens mission, 027 objectives, different roster):
  units with runtime values   21 -> 26
  csv rows                 1 827 -> 2 143
  defaulted-on-disc values   963 -> 1 059

New: UN_e004_ADAN_ElanPlus_N, UN_e006_ADAN_Vindicator_Margras,
UN_f002_TCAF_DeltaSaber_W, UN_f002_TCAF_DeltaSaber_W_Player (a second player craft)
and UN_mn040_Asteroid_Big (47 defaulted values, Size_Z = 2000).

Two traps recorded with the fix: launch_mission.sh presses A on the PRESELECTED
slot, which is the last-used one (03 here), so a probe in slot 01 is ignored — the
first attempt flew stage 02 again and the solver correctly found 0 new units; patch
every slot or confirm the highlighted slot by screenshot. And back up before
patching: the mid-run backup already held the probe, so slot 01 was restored from
the earlier pristine copy (md5 142b4f43…, verified byte-identical).

Token file for the solver: idxd_tokens <GP_MAIN_GAME_E.pak> Generic — 110 UN_*
records, the disc's full unit count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 13:16:04 +00:00
d1b50dc1e6 re(xbg7): characterise the residual 47 misses — 99.25% of resources decode
examples/why_missed.rs reports the furthest gate per undecoded resource. Disc-wide
that is 47 rows / 43 distinct names, and they are mostly not ship geometry:

  30  e_rou_/_rou_ pose & proxy composites (24-vertex marker boxes, extent 0.010)
   6  .DAT particle composites in ptc_pack
   8  damage/LOD variants (e101_bdy_02_d, e901_wing_05_*_m, ...)
   3  props/other (g005 extent 0.196, _rou_f001_wep_05, e_rou_e005)

By gate: extent 32, coverage 8, winding 7. The extent bucket is almost entirely the
pose-proxy boxes, and lowering that floor was measured and refuted earlier. The one
genuinely interesting residual is e901_wing_05_L/R at winding 0.587/0.570 against the
0.70 floor — the signature of a thin double-sided sheet, unproven without a capture
with the boss on screen (flying stage 16 puts the container in memory but the unit
never appeared).

So: 6 247 / 6 294 resources decode (99.25 %), and every real mesh drawn in three
captured missions decodes at the GPU's own offsets with index runs matching byte for
byte (93/93 and 128/128).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 12:50:19 +00:00
3b35c48fce fix(xbg7): grouped selection prefers the candidate explaining the whole pool; per-sub-mesh decls on by default
With per-sub-mesh declarations enabled, n201_01 decoded as a 2-part fragment 4 bytes
off. Both starts validate for the pivot — 0x32BA718 at pad 2 (earlier in file order,
so first-match took it) and the capture-proven 0x32BA71C at pad 0 — so the pivot
alone cannot separate them; at the early one two of four sub-meshes fall out as
out-of-range.

anchor_grouped_meshes now builds each accepted candidate and keeps the one that
explains the most of the declared pool: it returns immediately when a candidate
explains all n sub-meshes, else keeps the best partial, so it can never decode less
than first-match did. n201_01 lands on all four capture-proven offsets
(0x32BA71C / 0x32BEFF4 / 0x32C416C / 0x32C536C) and its two sibling copies take their
own pools, so the twin collapse is gone.

XBG7_SUBMESH_DECLS is therefore on by default (=0 reverts):

  resources that never decode        85 -> 47
  resources decoding in no container 63 -> 30
  degenerate index runs               1 -> 1   (unchanged)
  cross-container minority decodes   96 -> 96  (unchanged)
  captured index runs, stage-02      93/93     (unchanged)
  captured index runs, stage-05     124/128 -> 128/128

The last line is the point: the buffers the capture could not name are the n201
family, and they now decode and match the GPU's indices byte for byte. Suite green
including twin_pairs_do_not_share_a_buffer, apart from the pre-existing
known-failing cross-container consistency test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 12:27:56 +00:00
4cdbbc2d48 feat(xbg7): read the per-sub-mesh vertex declarations (XBG7_SUBMESH_DECLS, off by default)
desc_dump shows each index marker is followed by its OWN element triples: n201_01
declares strides 24, 24, 24 and 28 (the last sub-mesh has a fourth element), which
matches the runtime capture's stride=28 on that draw and the four distinct vertex
shaders. parse_vertex_decl read the first declaration for the whole pool.

all_vertex_decls reads one per marker; anchor_grouped_meshes uses each sub-mesh's
own stride for the pool walk, the pivot validation and the read. debug_grouped_report
follows the same setting so the diagnostic cannot blame the wrong gate — at n201_01's
capture-proven pool start it now reports "pad 0: ACCEPTED" instead of a NaN position.

With XBG7_SUBMESH_DECLS=1: resources that never decode 85 -> 47, resources decoding
in no container 63 -> 30, capture oracles unchanged (93/93 index runs, 42/42 index
counts), consistency unchanged at 96.

Off by default because selection has not caught up: the three n201_0x copies then
settle on one pool (twin_pairs_do_not_share_a_buffer fails), production still picks
a start 4 bytes before sub-mesh #1 rather than the proven one even though the proven
start validates and is unclaimed, and four newly decoded ptc_pack .dat composites
carry degenerate triangles. Format truth is settled; choosing among candidates is
the remaining work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 11:45:20 +00:00
27577538cb re(xbg7): a capture names a never-decoding family, and the cause is mixed strides in one pool
capture_ib_truth now proposes an identity for each drawn buffer our decoder cannot
place, by matching (vertex, index) counts against declared-but-not-decoded
resources. That identified n201_01/_02/_03 in Stage_S02.xpr — three of the 63
resources that decode in no container — and the capture pins all four sub-meshes:

  #0 vb 0x32BA71C ib 0x32B39FC 4464 idx 777 v stride 24
  #1 vb 0x32BEFF4 ib 0x32B5CDC 4464 idx 869 v stride 24
  #2 vb 0x32C416C ib 0x32B7FBC  576 idx 192 v stride 24
  #3 vb 0x32C536C ib 0x32B843C 4464 idx 869 v stride 28   <- different

The layout matches our assumptions exactly (tight index packing, last buffer flush
against vb0 so pad 0, span 27936 == align4-summed markers, contiguous vertex
buffers, max index == verts-1 everywhere). The defect is that sub-mesh #3 has a
different stride AND its own vertex shader: anchor_grouped_meshes parses one
declaration per resource and applies its stride to every sub-mesh, so it reads #3
out of phase (372 non-finite position components of 2607), and since the pivot is
the largest index count with ties going to the last marker, the pivot IS that
sub-mesh — so the whole resource is declined.

Also adds examples/miss_targets.rs (which container to aim a capture at): 63
resources decode nowhere, 58 of them in exactly one container, clustering as
Stage_S16 21 (e901_wing_05_*), ptc_pack 12, Base 6, then per-stage n2xx groups.
Flying stage 16 did not draw the e901 wings — the container is resident but the
unit must also be on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 10:52:35 +00:00
92d36ec10f re(xbg7): a Stage-05 capture validates the pad fix out of sample, and shows how containers load
Ran ship_capture_close.sh in story stage 05 (reachable now via the save's stage
field) instead of stage 02: 3 logs, ~11 200 draws.

- Out-of-sample check of the pad-scoring fix: 124 of 124 index runs identical to
  the GPU's, 2 769 index elements, 0 differing — on draws the fix was not derived
  from (the stage-02 capture gave 93/93).
- A mission keeps SEVERAL stage containers resident: 65 drawn buffers place in
  Stage_S02.xpr at 0x17FE3FF4 (the shared TCAF/ADAN fleet) and 5 in Stage_S05.xpr
  at its own constant 0x1927B7F4 (the f101 ACROPOLIS, this mission's escort) —
  the first capture-truth rows for a container other than Stage_S02. Both sets
  have exact index counts and exact coverage.
- Why: f105_bdy_01 lives in 13 of the 22 stage containers and not in Stage_S05 at
  all, so the resident set follows the mission's unit roster, not the stage number.

Consequence for the residual misses: a different story stage is not a different
container. Aiming a capture at a specific container is a static question (resource
names per container x the stage's EnumUnit roster).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 10:20:00 +00:00
9fd5dcd37d re(savegame): GHAD +52 is the stage number — a hand-edited save plays Stage 05
Static analysis of the header builder (0x822870b4..0x82287128) shows the GDHA
container header carries a SUMMARY of the progress block, and the screens read
that: hdr+0x14 <- GHAD+52, hdr+0x18 <- GHAD+48, hdr+0x1c <- +24 (Points),
hdr+0x20 <- +4 (flight), hdr+0x24 <- the computed clear ratio, hdr+0x28 <- +12.
savegame_edit.py copies the donor header verbatim, so payload-only probes left a
stale summary — which is why +52 looked refuted.

Patching both (hdr[0x14] = 5, ghad[+52] = 5) makes the title read
"STAGE 05 - Star System Escape", load it, brief the Gallia Asteroid Group, and fly
an asteroid-field mission with a different objective and roster. So +52 is the
stage number, 1-based, and one u32 chooses the mission.

Also corrected in the GHAD table: +48 is the Game Status enum (0 = At Standby,
matching the screen's STATE_STAND_BY/STAGE_CLEAR/GAME_CLEAR list) and +12 is Times
Cleared, both via their header mirrors.

This unlocks runtime capture in containers other than Stage_S02 (the 85 XBG7
misses, box identity, ship-placement generalisation) and per-stage unit
definitions. Slot 01 was backed up and restored byte-identically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 09:46:08 +00:00
0cf9220d24 re(savegame): stage-unlock probes are negative, and the Details panel is not a payload oracle
Four probes against slot 01 (the save the title-menu screens read), each backed up
and restored byte-identically:

- SHAB[1..7] filled -> MISSION SELECT still locks Stage02-08, cursor will not
  leave Stage01. The per-stage record table is not the unlock gate; the earlier
  SHAB[1] attempt used a throwaway slot and could not show this.
- GHAD +36/+52/+56 = 5 -> still locked, panel still STAGE 02 / EASY.
- GHAD +16 = 4 -> panel still STAGE 02, so it is not a 0-based stage index.
- live-RAM writes into the loaded GDHA header -> panel unchanged.

The useful part is why the panel cannot answer this: guest RAM at that screen holds
NO payload bytes (neither the 4101/4101/79 triple nor the 54-byte develop blob),
but it does hold the save's GDHA container header with a summary copy of Points,
flight time, clear ratio and a FILETIME. savegame_edit.py copies the donor header
verbatim, so a payload edit leaves that summary stale — "the panel did not change"
therefore cannot separate "not the stage field" from "the panel never reads the
payload". Future stage/difficulty probes must patch the header too, or be judged by
what the game does on load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 09:18:46 +00:00
0776beb6f4 fix(xbg7): degenerate index runs 582 -> 1 (grouped path + prefer a clean candidate)
Two follow-ups to the pad-scoring fix, both driven by the same invariant (a
correctly located index run has no degenerate triangles):

- anchor_grouped_meshes picked its pad by first-match too; scoring the pivot run
  the same way cleared every remaining ptc_pack composite (f102/f104/e107).
- anchor_pool_mesh now prefers a degenerate-free candidate over an earlier dirty
  one. examples/better_home.rs showed the last two resources each had exactly one
  degenerate-free, pool-covering block, sitting later in file order than the
  lookalike we took. First-match order is kept for every clean hit, and a dirty
  block is still used if nothing clean exists, so coverage cannot regress.

  degenerate index runs, disc-wide:      582 -> 11 -> 1
  captured index runs identical:         93/93 (unchanged)
  resources decoded / misses:            6 209 / 85 (unchanged)
  index runs changed / anchors moved:    590 / 10 (_rou_f402_dead x8, e201_bdy_03_m x2)

Cross-container minority decodes 89 -> 96, and that is progress: all seven new
rows are _rou_f402_dead, which now has a majority (32x25x8) for the first time, so
its seven wrong copies are named instead of hidden behind "no majority".

The last dirty run (_rou_f402_dead in Stage_S09) is blocked by distinct assignment
— its clean block is claimed by e_rou_f003_Near, both 24-vertex bounding boxes. A
winding-floor escalation for that case was written, measured to fire for nothing,
and reverted; the reasoning is kept as a comment.

Regression threshold tightened to 1. Suite green with --include-ignored apart from
the pre-existing known-failing cross-container consistency test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 08:52:41 +00:00
33dc12c948 re(xbg7): render the before/after — the index shift shredded capital-ship hulls
ship_render --static on f101 (ACROPOLIS) with and without XBG7_PAD_FIRST_MATCH=1.
Identical triangle count, placements and bounds; the old wiring renders as a mess
of shards, the fixed one as a coherent hull. Kept as the evidence image for the
pad-scoring fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 07:28:40 +00:00
41b59faf5f fix(xbg7): the index run was one element late for 575 sub-meshes
Extending the capture comparison from index COUNTS to index VALUES
(`examples/capture_index_bytes.rs`, using the batch offsets the new ib logging
gives) showed 76 of 93 Stage_S02 index runs identical to the GPU's and 17
differing — every difference a shift by exactly one element, on buffers whose
index data sits at pad 2.

`anchor_pool_mesh` returned the FIRST pad that validated, and pad 0 is tried
first with the looser winding gate (0.70 vs 0.85). Read at pad 0, a pad-2 block
yields [true[1], true[2], …, garbage]: every index in range, the pool covered,
the positions right, the winding often just above 0.70 — so it validated, and
every triangle was mis-wired. Nothing count-based could see it.

The signature is decidable without the capture: a shifted run wires arbitrary
vertices, so triangles come out degenerate. 282 of 283 correctly anchored
Stage_S02 blocks have zero degenerate triangles, while the shifted readings carry
1–2 156. So score every validating pad by (degenerate triangles, then winding)
and keep the best. `XBG7_PAD_FIRST_MATCH=1` restores the old behaviour.

  captured index runs identical:            76/93  ->  93/93  (2 025 elements)
  decoded runs with a degenerate triangle:    579  ->     16  (disc-wide)
  sub-meshes whose index run changed:                    575  of 8 850
  resources decoded / vertex anchors / consistency:  unchanged (6 209 / same vb / 89)

Locked in by tests/mesh_disc.rs::decoded_index_runs_have_almost_no_degenerate_triangles.
Suite green with --include-ignored apart from the pre-existing known-failing
cross-container consistency test (the 24-vertex bounding-box class).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 07:26:52 +00:00
6d92e6c114 re(xbg7): the [index][vertex] layout is runtime-verified, and the indices= mystery was batching
The decoder's central unstated assumption — a block's index buffer sits
immediately before its vertex buffer (`vb - idx_count*2 - pad`, pad <= 3) — was
also the prime suspect for the residual anchor misses, since a capture-proven
`e106_eng_02_l` block was rejected outright. Measured it instead of assuming:

- extended the F10 ship capture to log each draw's index buffer (base, count,
  min/max index) and to key its de-dup on the index range, so every draw batch
  is recorded rather than only the first;
- `examples/capture_ib_truth.rs` places each drawn buffer in the container by its
  dumped positions and scores the capture against our decode.

Stage_S02, 42 drawn buffers placed: our idx_count == the sum of the draw's index
batches for 42/42, the batch union covers the vertex pool exactly for 42/42, and
all 30 single-block cases sit at pad <= 3 (20 at pad 0, 10 at pad 2). The other
12 are grouped pools, where one index pool serves the whole group. So the layout
holds, the decoded index count is exact, and eng_02_l died on the connectivity
gate (since replaced by the winding gate) — not on index location. The shipped
exact-coverage rule is independently confirmed.

The recorded "capture indices=21 vs our 246" disagreement was an artefact of the
old de-dup key: 21 was the first of two batches, 21 + 225 = 246. Any conclusion
from a pre-2026-08-13 capture's `indices=` or `vbase - ibase` is about one batch,
not about the block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 05:57:44 +00:00
1b618f500f re: cross-validate the code-derived layout against the earlier RAM-binding solver
Two independent derivations of the unit definition layout -- the earlier
disc-value-to-RAM-word solver and this session's extraction from the loader's own
key strings -- agree on 25 of 25 shared offsets, 0 disagreements. The code-derived
table covers 159 fields against 27 confirmed, while the earlier one uniquely holds
ScorePoint (+0x08c) and MassScore (+0x094).

Also flags a tentative entry as an artefact: Slalom_CutoffRatio at +0x00c records
2.8026e-45 / 1.4013e-45, which are the denormal readings of the integers 2 and 1 --
the same trap that produced a false YawDragFactor hit while building the new map.
INDEX updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:11:43 +00:00
7cd9fb8e0b feat(formats): land the verified unit-definition layout as data + API + test
The offset->field map read out of sub_82341A20 is now usable rather than just
documented: data/unit_definition_layout.txt carries the 159 fields with their
provenance and the two conventions (angles are degrees on disc and radians in the
object; a defaulted field keeps the accessor's 0.0 miss value), and
sylpheed_formats::unit_layout exposes fields()/field_at()/offset_of() so a memory
snapshot can be read by name.

tests/unit_layout_disc.rs replays the verification against the checked-in live
dump -- every mapped float of all 11 identified objects must equal its disc value,
angles compared in radians -- asserting 0 disagreements and >=400 agreements. It
needs no emulator. Full suite green: 11 binaries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:44:26 +00:00
19b338d9d8 re(route-b): map generalises to units -- 406/0 across 11 objects, 121 defaulted fields
Applying the map to turrets, fighters, an attacker and a station (UNIT records
rather than VESSEL) gives 406 field values agreeing with the disc and 0
disagreeing across 11 objects, so one layout serves both schemas.

Two consequences the four-value signature could not give: the map IDENTIFIES an
object -- 0xbd3b6a00 is UN_bf001_TCAF_SchlosBase (14 agree, 0 disagree), not
UN_be005_ADAN_SpaceFortress (12 agree, 2 disagree on Color_R/Color_G) -- and
variants are provably identical, with ArrowHead/_EX4/_EX5 at 48/48, Turret/_EX4
at 51/51 and three DeltaSaber player variants at 49/49.

121 defaulted fields now have runtime values. FiringRange (0 in all 11) and
ShieldRatio (1 in all 6) look like true constants; HQRatio, ThrusterRatio and
AttackCraftPoint vary per unit and are recorded as such.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:12:45 +00:00
d674cddad4 re(route-b): full field map verified 154/154, and 101 defaulted fields read out
Anchoring each key string to the first store AFTER the accessor call makes the
extraction exact: 159 fields, 125 of them floats, and verify_fieldmap now checks
every one against the live objects -- 154 agree with the disc, 0 disagree.

The last ten mismatches taught the rule: angle fields are degrees on disc and
RADIANS at runtime (AV_PitchPlus_Max 3.6 -> 0.0628319; MaximumBank_Normal 60 ->
1.0472), an exact conversion across every angle field and unit.

101 defaulted fields now have runtime values: angle limits default to 3.6 degrees,
afterburner/turn-attack fields to 0, FiringRange to 0 (matching the loader's miss
value), Size_Y to that unit's Size_X, and the ratio family is per-unit rather than
constant (HQRatio 0.2 or 1, DefencePoint 0.1 or 0.25, ...).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:44:57 +00:00
0434afa6a6 re(route-b): the field map comes out of the code, and with it the Ratio family
The filler builds each key as addi r4, r30, -N with r30 = 0x82088f94, so every
store's field NAME is a string in the image. Pairing keys with the following stfs
gives the layout outright: Size_X/Y/Z at +48/52/56, HQRatio +88, ShieldRatio +92,
ThrusterRatio +96, resistances +116..132, Radar/FCS/FiringRange +672/676/680,
Attack/Defence points +692/696/700. verify_fieldmap.rs checks it against the live
dump: 62 fields agree with the disc, 0 disagree.

That yields the runtime value of each field for units whose record omits it --
HQRatio 0.2/1, ThrusterRatio 0.2/1, ShieldRatio 1, ResistanceToPlayer 1,
ResistanceToShell 0.1, ResistanceToExplosion 0.5, AttackVesselPoint 0.1,
AttackCraftPoint 0.1/0.5, DefencePoint 0.1/0.25, FiringRange 0, Size_Y = Size_X.
Recorded with the caveat that several show two values across units, so these are
per-unit runtime values rather than one global default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:17:05 +00:00
2ed475868b re: Size_Y is genuinely defaulted, and the loader leaves it at 0.0
pool_window.rs shows the Destroyer's raw tokens: "200.0" "Size_X" "Size_Y"
"2000.0" "Size_Z" -- Size_Y is a bare key, so the 13 runtime values recovered
earlier are real defaults, not a reader artefact.

The filler reads each size field through sub_822FC5A8, which loads f31 from
0x8209fd28 = 0.0 at entry and returns it on a pool miss, then stores to +52.
Nothing else in the 15876-byte filler writes +52, so the definition leaves a
defaulted Size_Y at 0.0 and the runtime 200 is written later.

Two corrections: the +48/+52/+56 comparison block builds a size-class bitmask
against 1000.0 (constant 0x8209fd20), not a has-value mask; and both earlier
Size_Y derivation candidates are refuted (one is a conditional pick, the other
multiplies into a different struct).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:47:43 +00:00
be42e998bc re: the unit filler is sub_82341A20, and its 1.0 constant is not a default
Call graph completes: sub_82341048 allocates 880 bytes, calls the zero-filling
constructor, then sub_82341A20 (3969 instructions, 157 stfs, 892 calls) fills the
fields. A repeated lfs/stfs pattern writes the constant at 0x820854cc -- read from
the .pe as 1.0 -- into ten offsets, which looks like a blanket default.

Checking those offsets in the live objects first shows it is not: they hold
per-unit radian angles (10/15/16/30/40/45/60 degrees), so the 1.0 belongs to the
degree-to-radian conversion, not to a default. The ordinary default is 0 from the
constructor; non-zero defaults like Size_Y <- Size_X are specific derivations.
Incidental and useful: angle fields are radians at runtime, degrees on disc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:18:56 +00:00
71adafe7f7 re: the definition constructor zero-fills -- defaults are derived after parsing
sub_8233FAF8 (0x8233faf8) writes vtable 0x820af844 -- the class dumped from a live
mission -- and every one of its 32 stores writes zero, out past +872. So defaults
are NOT constructor immediates, which was the hypothesis this tick set out to
test. Since a defaulted Size_Y nevertheless reads back non-zero at runtime (always
equal to Size_X), the value must be derived by a post-parse fixup -- the same
shape as the inheritance rule derived statically from sibling records.

Two candidate derivation sites (0x82217e18, 0x823081e8) are recorded as leads
only: the first is a conditional assignment rather than a copy, and offsets 48/52
are generic enough to belong to unrelated structures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:49:48 +00:00
a3868fab5b re: the static route locates the reflective parser and reframes the defaults
sylpheed.db has the machinery named: IdxdLoad_Dispatch 0x824486c0, Idxd_Parse
0x82449640, Reflect_FindFieldIndex 0x8244a2f0, Reflect_SetField 0x8244a4b0.
FindFieldIndex looks a field up by NAME at runtime (strlen + compare), which
supports the reflective-read reading -- but Idxd_Parse is a text parser that
calls SetField on a target object, so a parsed record does exist and the earlier
'no flat record' hypothesis is weakened.

The useful consequence: Idxd_Parse only writes fields the text contains, so a
DEFAULTED field keeps whatever the constructor stored. The defaults are therefore
immediates in each definition class's constructor -- reachable by walking the call
graph above IdxdLoad_Dispatch -- not values to be scanned for in RAM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:21:21 +00:00
bab2f9e6ca re: three probes for the parsed record all miss -- hypothesis: there is not one
Searched RAM for HP 25000 (200+ hits, too common), for the rare float
AttackVesselPoint 0.08 (64 hits, no window holds the unit's other values, one hit
is a code constant), and for the ID string itself (two copies, each pointed at
from -0x10, but the surrounding 256 words hold none of the numbers because that
region is the IDXD string pool where values are ASCII).

Hypothesis recorded as 🟡: IDXD is reflective and the engine likely reads values
from the pool by key on demand, caching only the per-frame ones -- which is
exactly what the 0x820af844 object contains at any dump depth. If so, a defaulted
field has no value anywhere in data and the default is applied by code at read
time, so Route B for the Ratio/Count family needs the read path or the static
DuckDB route, not more scanning. The 13 Size_Y values worked because Size_Y is
one of the cached fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:00:48 +00:00
f4f3bcb20a re: a deeper window does not help -- the 0x820af844 object is not the definition record
Dumping 512 words instead of 96 finds exactly the same 4 of ~50 disc values, for
all five correlated units, so the rest of the record is not in this structure.
Also corrects the reason recorded last commit: get_f32 is NOT unreliable on
default-heavy records -- it resolves 46-57 numeric fields per unit, which is what
made the emptiness of the correlation measurable in the first place. The one
automated hit (YawDragFactor -> +0x0c) remains false: +0x0c holds the integer 2
as a denormal and collided with the float 2.0.

Next probe is a RAM-wide search for a unit-distinctive value, not a bigger window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:49:19 +00:00
892f1e3979 re: partial field map inside the live definition object, and why the automation failed
Anchoring on a unit whose disc record sets a field locates it in the live object:
Size_X/Y/Z (+0x30/34/38) and HP (+0x54) confirmed across five capital ships and
the player fighter; +0x74 (0.8) and +0x84 (0.97) are probable ThrusterRatio and
ResistanceParalyze but rest on a single anchoring unit; +0x40 varies per unit and
is unidentified.

live_offsets.rs automates the correlation and currently produces one hit which is
false -- YawDragFactor 2.0 collided with the integer 2 at +0x0c stored as a
denormal. Causes recorded: get_f32 is unreliable on default-heavy IDXD records
because the value-before-key pairing shifts, and 96 words only reaches +0x180
while RadarRange/FCSRange sit beyond it. Next run dumps 512 words.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:36:57 +00:00
3322202732 re(route-b): 13 defaulted Size_Y values read out of the running game
Live definition objects carry no name, so identity comes from the values:
unit_signatures.rs prints (HP, Size_X/Y/Z) for every disc unit and vessel, and a
live object is the record whose known fields it reproduces. 7 of 14 match a disc
record outright; the other 7 match nothing because their records default a field
-- 18 of 23 vessel records are missing at least one, nearly always Size_Y.

Matching each single-default record against the live object that reproduces its
remaining fields resolves 13 values (Destroyer 200, Cruiser 600/700, ASFrigate
80, ADAN Destroyer 300, Acropolis 400, ISCMissile 300, plus _Inv/_EX variants).
Every one equals that unit's Size_X, so the engine's own parsed definitions
confirm the statically-derived inheritance rule.

Limits stated: variants share one live object, and the ...Ratio/...Count family
is not resolved -- their offsets in the live object are still unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:28:55 +00:00
a836f8e9ea re: live unit definitions from a running Stage 02, and the recipe that gets there
The runtime track is unblocked. Two environment facts cost most of the effort:
audio must be --audio --apu=sdl WITH SDL_AUDIODRIVER=dummy (muting with --apu=nop
fills the log with CreateDriver failures and the guest never passes the movie),
and everything must happen inside ONE blocking foreground call because anything
outliving its call is reaped. Boot is 25s to the flight HUD once caches are warm,
which is what makes launch-and-dump fit in one call. numpy/PIL are absent so the
analysis scripts do not run; gmem.py is stdlib and does.

Result: 14 live definition objects dumped (vtable 0x820af844, 96 words each).
The +0x54 column reads as HP across three orders of magnitude and +0x30..0x38 as
Size_X/Y/Z, corroborating the earlier single-snapshot layout, and five of the
fourteen have Size_Y == Size_X exactly, supporting the inheritance default rule.
Open: no name/id field in the first 96 words, so objects are not yet matched to
their disc records.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:18:32 +00:00
4d8c1b9457 docs: bring INDEX and BACKLOG up to the measured state
INDEX's XBG7 row still said 5480 decode (87.1%) and 125 of 681 shared resources
failing; it is now 6209 of 6294 (98.7%) with zero real meshes disagreeing across
containers and 46/46 capture-verified drawn buffers. The capital-ship row blamed
an XBG7 mis-decode for the user-reported assembly bug -- that mis-decode (a shared
turret ~100x too large) is fixed, so the BACKLOG entry is marked resolved at the
format layer, keeping the viewer-side pointers and the note that no metric caught
it: it took rendering the ship and looking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:02:54 +00:00
33a7bf836b perf(mesh): memoise the whole-container decode
Filtering after the assignment made every subset query a full decode (~15s on a
50MB container). full_decode_cached memoises it per container -- fingerprint is
length plus three sampled 4KB windows, keyed with min_consistency, last four kept.
Decoding five ships from Stage_S02 in turn: 10.5s for the first, then 48us-1.4ms.
A stage now costs one decode rather than one per ship. Ten suites green, viewer
builds, and a spot-checked resource still lands on the same offset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:57:50 +00:00
c79c0f6ccf fix(mesh): filtered decode must not depend on the requested subset
models_named pruned to the wanted set BEFORE distinct assignment, so collision
resolution saw a different resource population and returned different offsets:
27 of 356 resources in Stage_S02 decoded differently when asked for alone,
including real geometry (f001_bdy_30, f106_sld_02_l/m/d, f101_wep_01_l). Both the
viewer and assemble_ship decode subsets, so both could disagree with the
container's own answer. This was a regression from distinct assignment itself.

Fixed by filtering the OUTPUT: the assignment always runs over the whole
container. One-name, three-name and full decodes now agree exactly. Cost: a
single-resource query on a 50MB container goes from near-instant to ~15s;
per-container caching is the follow-up. Ten suites green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:47:41 +00:00
e8434541f2 re: monotone assignment refuted again -- box identity needs descriptor data
XBG7_MONOTONE=1 enforces descriptor order per (stride, vtx, idx) signature: 89
minority decodes, unchanged. Monotonicity constrains order WITHIN a container
while the disagreement is BETWEEN containers, which hold different numbers of
these boxes in different arrangements. The remaining 89 are bounding-box identity
ambiguities needing descriptor-level information, not another anchoring
heuristic. Knob kept, default off, measurement recorded; suites green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:30:58 +00:00
1d8b783d26 re: all 89 remaining disagreements are 24-vertex boxes -- no real mesh disagrees
Checking the vertex count of every minority decode: all 89 are 24-vertex
resources, including e101_wep_01_l, which last commit called the one real
geometry disagreement -- it is a 24-vert box too. So after this thread's fixes no
real mesh on the disc decodes differently in different containers; what remains
is structurally identical bounding boxes swapping identities, which needs an
ordering rule rather than a validity gate. Monotone anchoring was refuted before
but under a decoder with other defects, and is now cheap to judge: 89 -> 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:11:20 +00:00
cdaeac8e1b re: the consistency figure is mostly composite bounding boxes -- real disagreement is 1
consensus_check names the minority container instead of just flagging
disagreement: 89 minority decodes across 477 resources with a majority, and 88
are scene composites. A composite's descriptor carries a 24-vertex bounding box
(e_rou_e106 -> 22x22x22, e_rou_f106 -> 745x718x718); those boxes look
interchangeable so the assignment shuffles per container. 1141 of 6209 decoded
resources have scene nodes. Counting only real geometry the disc has ONE
disagreement: e101_wep_01_l in Stage_S25.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:02:06 +00:00
0625976ed7 re: the coverage fix moved 29 anchors, 22 of which carried another resource's geometry
Whole-disc comparison at XBG7_COVER_SLACK=4 vs =1: 29 resources changed anchor,
3 stopped decoding, 6207 unchanged. Of the moved ones present in several
containers, 0 matched the sibling consensus before and 9 after -- it moved them
onto the block their own copies agree on and none away. 22 of 29 were carrying
ANOTHER resource's geometry under their own name, including a shift chain in
Stage_S28 where n054_bdy_l held n056_bdy_l's mesh and n056_bdy_l held
n055_bdy_l's. Confidently-wrong-under-the-right-name is invisible to every
count-based metric; the coverage invariant is what separated them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:40:22 +00:00
095816834a re: CORRECTION -- the static assembler is not at fault, the pre-fix decoder was
Last iteration reported e303_wep_01 as inflated 30-110x by assemble_ship. Wrong:
the composite nodes carry scale 1.0 and orthonormal matrices, and under the
current decoder the turret places as 49x23x42 at +/-179. The 1600x2100x4800 box
only appears with XBG7_COVER_SLACK=4 -- the exact-coverage fix had already
repaired the turret too. The error came from comparing a deliberately pre-fix
render against post-fix measurements.

The containment screen's blindness therefore has a mundane cause: one mis-decode
inflated the envelope and hid another. Kept as a forward-looking invariant with
that caveat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:28:18 +00:00
27fd9f186f re: containment screen fails too -- the static assembler inflates a shared turret
envelope_screen measures per-axis protrusion past the sibling envelope, the
relationship the eye used. It still does not flag e106_bdy_03, and dumping the
static assembly shows why: e303_wep_01's world box is 1600x2100x4800 around a
~400x400x2000 hull, so nothing can protrude. The decode is innocent -- that
resource is 49x23x42 in every container -- so the static assembler is inflating
it 30-110x per axis, non-uniformly. The screen is only meaningful once placement
is trustworthy, and the assembler now has a worse defect than the decoder had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:20:52 +00:00
85ad540a7e re: the other four ships are clean; automating the slab check failed
Rendered f101/f105/f106/e105 statically -- all coherent, no stray masses, so the
e106 slab was specific. slab_screen.rs tries to automate the check by comparing
each part's min-axis extent to its ship median, but it produces identical flags
with and without the coverage fix at both 4x and 2.5x: it never sees
e106_bdy_03, the part it was built for. The eye used relationship (a mass apart
from the hull), not scale; a containment test is the right analogue and is not
built. Kept for what it does show: f002_bdy_22's 100000-unit tether and
t901_e01_D's mast are legitimate, and capture-verified f101_bdy_01 flags at 6x.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 07:22:43 +00:00
f340815db6 fix(mesh): require exact pool coverage -- found by rendering, not by a metric
Rendering assembled e106 from Stage_S02 showed the old slab: e106_bdy_03 spanning
600x1600x998 where three other containers give 276x236x941. Its anchor had slack
3, and the coverage gate tolerated up to three unreferenced tail vertices --
tolerance that was hiding a mis-anchor, since real blocks reach their last vertex
exactly (8580 of 8629). Requiring exact coverage moves it to the block the other
containers agree on and the slab disappears.

Costs 3 resources (6212 -> 6209), inconsistency 39 -> 38, capture oracle
unchanged at 46/46, suite green. Evidence: captures/e106-cover-slack-before-after.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:56:49 +00:00
9d33345560 re: the grouped pivot floor is not the blocker either -- anchor work plateaus at 98.7%
Sweeping XBG7_GROUPED_CONSISTENCY 0.85 -> 0.80 -> 0.75 decodes no additional
resources and leaves inconsistency at 39, while changing which geometry some
grouped models get -- strictly worse, so 0.85 stays. With the degeneracy/extent
fix already refuted, the remaining 82 misses are not a threshold away; they need
a structural answer (marker lists that do not map onto stored blocks), which
means a capture, not more tuning. Records the state of the four shipped changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:34:41 +00:00
2f0ce19f68 re: 98.7% coverage, and the scale-free degeneracy fix is refuted
Under the winding gate, coverage is 6212/6294 (98.7%) with 82 misses left,
attributed 42 degenerate/extent, 31 winding, 9 coverage, 0 connectivity. The
biggest bucket turns out NOT to be the blocker: replacing the absolute area test
with a scale-free collinearity test decodes no more resources and raises
inconsistency 39 -> 44, and dropping the extent floor to 0.05 adds two. Both stay
as opt-in knobs (XBG7_REL_DEGEN, XBG7_MIN_EXTENT) rather than defaults. Also
fixed another stale default label in edge_cap_sweep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:19:38 +00:00
f835705b48 fix(mesh): winding consistency replaces the connectivity heuristic
Gate attribution put connectivity behind 153 of 225 misses -- the largest
blocker, and the gate already known to reject a capture-proven block. Testing it
against winding consistency (an objective topology signal: ~1.0 or ~0.0 for a
real mesh, ~0.5 for a mis-carve) shows a floor of 0.70 with connectivity inert
dominates the previous default on both axes: 6212 resources decoded (+143) with
39 shared inconsistencies (-17), capture oracle unchanged at 46/46, twin
invariant clean, coverage invariant intact. Suite green.

The 0.80 cliff (5770 resources, 0 inconsistent) is documented, not taken --
consistency is the weaker witness, and both points are one env var apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 05:47:01 +00:00
ee2b98341d fix(mesh): pre-pivot sub-meshes must cover their pool, not just index in range
The last two blocks that under-covered their vertex pool were f102_break.dat and
f104_break.dat in ptc_pack.xpr, each reading a neighbouring block's index buffer
against the wrong declaration (414 verts indexed to 404; 160 indexed to 79).
Their marker lists do not map onto the stored blocks -- only 2 of 9 and 4 of 10
sub-meshes decoded at all. Requiring coverage (max_idx + 4 >= vtx_count) for
pre-pivot sub-meshes drops exactly the mismatched pieces.

Every decoded sub-mesh disc-wide now covers its pool: 8580 at slack 0, 49 within
tolerance, none beyond, none negative. Coverage 6069/6294, inconsistency 56,
truth table 46/46 -- all unchanged. Suite green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 04:57:49 +00:00
6634d79c2e fix(mesh): grouped pools no longer emit sub-meshes with out-of-range indices
coverage_audit measures index coverage per decoded sub-mesh. 8586 of them
reference their last vertex exactly, so the 'buffer not covered' gate is well
founded -- but 18 had NEGATIVE slack: indices up to 364 vertices past the end of
their own buffer, emitted because anchor_grouped_meshes reads pre-pivot
sub-meshes unconditionally. Quality gates stay relaxed there (a tiny flat lead
part is legitimately poor) but index range is now required.

Coverage 6069/6294 unchanged, inconsistency 56 unchanged, truth table still 46/46
claimed, suite green; the vertex total drops by exactly the 1546 garbage verts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 04:41:17 +00:00
c9c98cf942 re: coverage is 96.4% (6069/6294) -- and the 225 misses get a gate breakdown
undecoded.rs supplies the denominator the coverage numbers never had; the disc
holds 6294 XBG7 resources, 6069 decode, 225 are searched and missed, 0 lack a
descriptor. gate_histogram.rs attributes each miss to the furthest gate its best
candidate reached: 120 connectivity, 74 grouped-pool (different path), 15
degenerate/extent, 9 winding, 7 buffer-not-covered. Recorded as a work-list, not
a verdict -- a wrong candidate can pass more gates than the true block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 04:22:43 +00:00
db8104ab99 re: n206 was fixed after all -- the audit was classifying by geometry, not buffer
debug_grouped_report (why_rejected) shows n206_02's alternative pool is ACCEPTED
at pad 0 under production gates and is in the candidate list -- and the decoder
does take it: n206_02 now anchors at 0x342d984. The 'still collapsed' reading
came from the audit comparing decoded geometry, and that offset holds a direct
(unmirrored) copy, so a separated pair still looked identical. The audit now
requires a SHARED BUFFER to call it a collapse: disc-wide 18 exact mirrors, 16
related, 0 collapses, 0 unrelated -- and the regression test drops its exception.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 04:10:05 +00:00
bb0c94c0ce fix(mesh): distinct assignment now covers grouped pools too
anchor_grouped_meshes takes the same taken set; a colliding grouped model is
re-placed whole past everything claimed, or keeps what it had. Runtime oracle
unchanged (45/46 exact, 0 unclaimed), coverage unchanged (6069), cross-container
inconsistency 62 -> 56, suite green.

It does not clear the n206 twin collapse: no alternative pool validates for the
loser, so that pair is a validator case (like eng_02_l before the cap move), not
a selection one. Also fixes a stale '0.28 (default)' label in edge_cap_sweep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 03:53:10 +00:00
fd34d350f2 test: lock in the twin invariant -- 0 unrelated pairs disc-wide, 1 known collapse
twin_mirror_audit applies the capture-derived rule to all 166 containers: of 34
equal-count twin pairs, 18 are exact X-mirrors, 15 related another way, 1
identical, 0 unrelated. Two calibration fixes were needed first (authored halves
need a tolerance, and a mirrored pair may be stored in another vertex order).
The one collapse, n206_01/_02, is a grouped-pool pair -- the path distinct
assignment excludes -- so it names the next target. Added a disc-gated
regression test; refreshed the stale ignore message on the consistency test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 03:44:29 +00:00
55e1fe3783 re: the break-composite alarm is false -- the container stores 3-6 copies of each buffer
locate_draw counts copies of a captured buffer. The f105 parts _rou_f105_break
claims exist 3-6 times over (the 2336-vert one: 6 direct AND 6 mirrored), and
every live LOD checked is anchored on a direct, byte-identical copy -- so the
composite taking the drawn copy costs nothing. This also calibrates the oracle:
'exact' (anchored at the drawn offset) is stricter than correct, so 45/46 is a
lower bound. Open: a resource landing on a MIRRORED copy would be a real defect
invisible to every count-based metric.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 03:32:58 +00:00
7f1ac41563 fix(mesh): raise the connectivity cap 0.28 -> 0.42 on runtime evidence
Swept against the 46 capture-named Stage_S02 buffers with distinct assignment in
place: 0.28 anchors 40 exactly and leaves 4 unclaimed; 0.42 anchors 45 and leaves
none; nothing above 0.42 improves further, so it is the least permissive value
that captures the whole gain. 589 more resources decode, nothing is lost. The
cross-container consistency proxy worsens (46 -> 62) -- it cannot see a
systematic mis-anchor, and where the two disagree the capture wins. Suite green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 03:25:50 +00:00
d83bdd3b68 fix(mesh): distinct anchor assignment -- no two resources may claim one buffer
Selection was per-resource and greedy, so two resources could take one vertex
buffer while a valid one sat unused. A runtime capture proved that wrong for the
mirrored e106 hull twins: the container holds both halves and the engine draws
each from its own address. Now the first claimant keeps a buffer and later
resources re-anchor past everything already claimed (coverage can never regress;
grouped-pool models untouched).

Against the 46 capture-named Stage_S02 buffers: exact anchors 29 -> 40, unclaimed
12 -> 4. Disc-wide: 5480 resources decoded (unchanged), cross-container
inconsistency 125 -> 46.

The twins' mirror therefore lives in the DATA, not in the placement matrix: the
embedded e106_bdy_02 row and the two assertions encoding the old convention are
updated, each with the reason recorded. Full suite green incl. disc/ISO gates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 03:16:51 +00:00
1815490d01 re: scored against the runtime, relaxing the cap is monotone -- the regressions are collisions
Against the 46 capture-named Stage_S02 buffers: 0.28 gives 29 exact anchors,
0.42 gives 31 with 0 lost and 2 fewer unclaimed. The 18 cross-container
consistency regressions are a different phenomenon: the decisive one is the
32-vertex buffer at 0x24ce5f4, unclaimed at 0.28 and claimed by BOTH f303_body_l
and e302_barrel_l at 0.42 -- a collision from greedy per-resource selection, not
a mis-placement. So the cap and the selection must change together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 03:06:15 +00:00
b58f42e46d re: the capture is Stage_S02 -- provenance corrected, oracle grows to 46 buffers
capture_truth_scan places every drawn buffer across all 166 containers by modal
vbase-offset. Stage_S02 wins with 64 matches at one constant against Stage_S01's
16, and the logs carry f101/f105/f106/e105 -- a Stage-02 cast. Stage_S01 looked
consistent because the shared block is duplicated verbatim (twins 0x116F0 apart
in both), so the earlier structural findings hold; only the loaded-container
claim was wrong. The S02 table places 46 buffers, 12 claimed by nobody, and
resolves the six Stage_S01 mystery buffers as e105 parts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 02:59:40 +00:00
df7a10749a re: calibrate the connectivity cap -- +612 resources against 18 consistency regressions
XBG7_EDGE_CAP / XBG7_SMALL_TRIS make the threshold sweepable (defaults unchanged,
full suite green). Above 0.417 the capture-proven eng_02_l anchors exactly right
and no e106 part regresses, and nothing that decoded at 0.28 is lost -- but ~250
existing anchors move silently and 18 shared resources lose cross-container
consistency. Not changed: the movers have no oracle yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 02:47:07 +00:00
845b63d7dd re: eng_02_l's real block is adjacent -- the connectivity heuristic rejects it
debug_find_index_buffer scans the container for an index buffer that validates
against a capture-proven vertex buffer. For eng_02_l nothing validates with the
connectivity test on; with it off the nearest hit is exact pad-0 adjacency
(vb-ib = 144 = 72*2). The block's mean_edge/diag is 0.417 against a 0.28 cap --
the documented false positive for coarse LODs, now caught with ground truth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 02:31:53 +00:00
dde52c02e7 re: correct the 3.4% reuse figure -- the capture dedups by (vbase, transform)
Read off the emulator patch rather than inferred: CaptureShipDrawForRE dedups by
(vbase, WVP hash), so a buffer drawn repeatedly at one transform appears once.
The 3.4% counts multi-instance placement, not buffers serving several parts, and
is not the population evidence about sharing it was written up as. Also pins
vcount = buffer capacity and indices = that draw's num_indices.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 02:24:44 +00:00
51723af59a re: the twins' correct block validates -- distinctness is a real fix; eng_02_l is not
debug_try_anchor asks validate_block directly at the capture-proven offsets. Both
119-vert twin buffers are accepted by both twins (so the correct block lost the
first-match race, and a distinct assignment fixes it); the drawn 51-vert bridge
buffer is accepted by both bridge resources; eng_02_l's proven offset is rejected
outright, even with the pad widened to 64 -- a validator gap, not a selection one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 02:17:55 +00:00
76a433f75c re: the proven offsets ARE candidates -- these mis-anchors are selection failures
debug_vertex_run_starts exposes the anchor scan's candidate list: Stage_S01 has
15710 stride-24 starts and all three capture-proven offsets (0x3c55d8 twin,
0x40e418 bridge, 0x44a32c eng_02_l) are among them. anchor_pool_mesh takes the
first that validates, so an earlier lookalike wins. Scoped to the current
decoder's e106 cases; does not overturn the residual-51 finding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 02:11:41 +00:00
26f1bdf6fd re: exact anchor offsets (GameMesh.vbuf_offset) -- correcting yesterday's defect table
The first read of the capture-truth table located our resources by searching the
container for their leading vertices, which reads much worse than reality: the
same leading run occurs at several offsets in one container. GameMesh now carries
the offset the anchor scan actually chose, so the comparison is exact -- 4 of the
ship's drawn buffers are anchored correctly, 2 are the twin collapse, and 2 are
real mis-anchors of a size we do decode (brg 51 verts, eng_02_l 44).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 02:04:55 +00:00
3245aca8cb re: the capture names file offsets -- the twin collapse is an anchoring error
Locating each drawn buffer's positions inside Stage_S01.xpr shows vbase - offset
is one constant (0x1A94FFF4, same in two runs), so a capture gives ground truth
at file-offset granularity. Read against our anchor scan it is a defect list:
full/_m resources starting at their own _l buffer, eight drawn buffers claimed by
nobody, and the bdy_01_l/bdy_02_l twins sharing one buffer while the container
carries both halves (0x3b3ee8 and its exact X-mirror at 0x3c55d8).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 01:55:13 +00:00
aa82d293a9 re: the capture logs are the sharing oracle -- engine reuses a buffer only 3.4% of the time
The raw Canary capture logs from the 2026-07 sessions are still on disc at
/sylph-home/re/shipcap/xenia_ship_capture_*.log, and their DRAW lines carry
vbase -- the GPU vertex base, i.e. ground truth for which buffer the engine draws
a part from. That is precisely the oracle the shared-decode question needs, and
it needs no new capture run.

Restricted to the ship-geometry stride 24, across the three logs: 6093 draws from
2291 distinct vbases, and only 77 vbases (3.4%) drawn more than once. So one
buffer serving several parts is the exception in the engine, while our decoder
has 19% of resources sharing geometry.

The measures differ (draws-per-buffer vs resources-per-geometry) so this is
evidence not proof, but it points the same way as e106_bdy_02 == e106_bdy_03_m:
much of that 19% is anchoring error rather than asset reuse.

Per-part use is already available: correlate_capture matches a draw to a resource
by vcount plus decoded positions, and the same match yields each part's vbase --
so two resources our decoder gives identical geometry can be checked directly,
different vbase meaning our shared decode is wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 01:40:56 +00:00
6d96446735 re: the descriptor does not address geometry, and consistency does not prove reuse
Two follow-ups, both negative, both worth recording so the next attempt does not
repeat them.

The descriptor cannot replace the candidate scan. If entry.data_offset located
the geometry, anchoring would be direct addressing. Across e106's resources in
Stage_S01, anchored_vb - data_offset ranges from 1199052 to 4173988 with no
constant or stride; data_offset locates the DESCRIPTOR and none of the first six
descriptor words tracks the vertex pool.

And yesterday's "1241 of 1242 pairs identical in every container, so sharing is
legitimate reuse" is weaker than it read. A SYSTEMATIC error is invisible to a
consistency test, because it is consistent. The same dump shows e106_bdy_02 and
e106_bdy_03_m anchoring to the identical offset (1505556), and e106_bdy_01_l with
e106_brg_01_m (4251208) -- a hull half and a different body's medium LOD, a hull
half and a bridge LOD. Different parts; one of each pair is wrong.

Honest position: sharing is common (19%), some is certainly legitimate (a
mirrored twin pair genuinely shares one geometry) and some certainly is not, and
cross-container consistency cannot separate them. What can: comparing a shared
pair against a runtime capture.

Instrumentation reverted; only docs land here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 01:32:34 +00:00
f655c90b0c re: shared decodes are mostly legitimate reuse -- correcting yesterday's claim
I wrote that "two distinct resources sharing one decode is itself the bug" when
withdrawing the neighbourhood anchor. Measured, that is wrong.

1043 of 5480 decoded resources (19%) share geometry with another resource, and of
1242 related pairs that share a decode somewhere, 1241 are identical in EVERY
container they co-occur in. That is what legitimate asset reuse looks like, not a
defect. A mirrored pair like bdy_01/bdy_02 is SUPPOSED to share one geometry with
the reflection applied at placement -- which is precisely what apply_twin_mirrors
does.

So the twin-mirror regression was never about sharing. It is about WHICH of two
mirrored buffers is canonical: the disc holds an X+ and an X- version, the engine
treats one as the base, and the neighbourhood anchor moved these resources to the
nearer buffer, which is the other one. The fix is to pin the canonical buffer
with the capture as oracle, not to split the twins apart.

Exactly one pair is provably mis-anchored by this test: e105_bdy_02_l /
e105_brg_m, identical in 7 of the 15 containers holding both and different in the
rest -- two names cannot be the same geometry only sometimes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 01:25:44 +00:00
aedad811bc test: fix the stale default disc paths -- three tests could never run unaided
texture_disc.rs and mesh_disc.rs fall back to "/home/fabi/RE Project Sylpheed/..."
when SYLPHEED_ISO / SYLPHEED_RES3D are unset. The directory is "RE - Project
Sylpheed"; the fallbacks were missing the " - ", so stage_models_decode,
stage_models_sweep and stage_models_quality_audit failed with NotFound for anyone
who did not happen to know the env var. They now pass unaided (10/10 in
mesh_disc).

Found while auditing what the suite actually exercises after withdrawing the
anchor change: an ISO-gated test skipping, and an ignored test failing on a typo,
are both "green" in a normal run.

Full state, with SYLPHEED_ISO/DISC/RES3D set and --include-ignored: 82 lib tests
and every disc/ISO suite pass; the single failure is
shared_resources_decode_identically_in_every_container, which is deliberately
written as the target state and #[ignore]d.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 01:18:10 +00:00
64d372c7e8 revert(mesh): withdraw the neighbourhood anchor -- it regressed the e106 twin mirror
The neighbourhood anchor (f18d591) and its refinement (27a0701) took
cross-container inconsistency from 125 to 51 with coverage unchanged, and made
e106 render as a destroyer rather than a slab. Both are reverted.

ship::tests::static_assembly_matches_runtime_capture is gated on SYLPHEED_ISO, so
it SKIPS in an ordinary cargo test -- which is why the regression was invisible
in every suite run so far. With the ISO it fails:

  e106_bdy_01: static M row0 [-1.0, 0.0, 0.0] != captured [1.0, 0.0, 0.0]

e106_bdy_01 and _02 are a mirrored pair whose two buffers hold the same geometry
reflected in X, and BOTH resources currently decode to the SAME buffer (identical
counts, span and mean_x). apply_twin_mirrors picks which instance to reflect from
the sign of that mean_x, so which buffer wins flips the decision:

  before  both twins mean_x = -66.83  -> mirror bdy_02  (matches the capture)
  after   both twins mean_x = +66.83  -> mirror bdy_01  (contradicts it)

Neither is right -- two resources sharing one decode is itself the bug and the
mirror heuristic has been compensating. The capture is ground truth, so a change
that contradicts it does not ship. The real fix must give each twin its own
buffer first.

Kept from the attempt: this test now also asserts the SET of static placements
against the capture (allow-list {e303_wep_01} for vbase dedup), so extra
placements can finally fail it -- the direction it could never fail in before.

Docs, backlog, INDEX and the ignored test's message all corrected to say
diagnosed-not-fixed rather than fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 01:08:41 +00:00
0f9c95c52e re: selection fixes are exhausted for the last 51 -- the correct block is not a candidate
Implemented the proposed tiebreak as a last resort (take the attractor only if
nothing else validates), keyed first on "all coordinates multiples of 50" and
then on the sharper "all coordinates integral" -- the attractor reads
(42, -900, 2400), (-600, -500, -950) while real geometry carries fractions like
(24.55, 9.84, 4.46). Neither changed anything: still 51 inconsistent, 5480
decoded.

The null result is the finding. A mechanism that defers the attractor whenever
another candidate exists, and changes nothing, means no alternative candidate
validates for any of the 51: the correct block is not in the candidate list at
all. So the residual is not a selection problem and no reordering, scoring or
tiebreak will move it.

The frontier is vertex_run_starts -- the unit-normal run scan that builds the
candidate list -- which emits no start for these resources' real vertex buffers.

Both attempts reverted; only docs land here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:57:09 +00:00
343a26ecc0 re: the remaining mis-decodes share one attractor, not pairwise confusion
Tested the tempting explanation for the last 51 -- that _l/_dead variants share
their base's vertex and index counts, making them mutually confusable and
adjacent so locality cannot help. It is FALSE: 2714 variant/base pairs across
every container, and exactly zero share identical counts.

What is actually happening is better news. e010_bdy_01_l is 171 verts / 90 tris
and NO other resource in its container shares those counts, yet in Stage_S02 and
S26 it decodes to 1600x2100x4800 -- the same bounds e303_wep_01 (172/110)
produced before the fix. Differently-shaped resources land on the same place, so
the attractor is a region of round axis-aligned box data that validates for many
(vtx, idx) shapes at once, not "another mesh with my shape".

That also explains why the neighbourhood fix helped so broadly: it steers
resources away from one strong attractor rather than resolving many pairwise
confusions.

Candidate next step recorded with its risk: de-prefer candidates whose sampled
positions are all multiples of 50 with a large span. It must be a TIEBREAK
between validating candidates rather than a rejection -- an earlier sweep for
that signature also flagged legitimate e_rou_* composite proxies -- and it needs
the same gate (decoded must not fall from 5480).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:46:32 +00:00
27a0701e0d fix(mesh): refine the anchor map before using it -- inconsistency 63 -> 51
Pass 1's anchor map contains exactly the mistakes the neighbourhood is meant to
correct, so a resource sitting beside a mis-anchored neighbour inherits a bad
reference. Re-anchoring against the improving map and repeating converges
quickly: two rounds, and a third changes nothing (the loop exits early when a
round is a fixpoint).

  before                 decoded 5480/6294  inconsistent 125
  neighbourhood anchor   decoded 5480/6294  inconsistent  63
  + refining the map     decoded 5480/6294  inconsistent  51

Coverage still unchanged. The 51 that remain cluster in _l (LOD) and _dead
variants -- e001_l, e010_bdy_01_l, e106_eng_02_l, _rou_f302_base_dead,
e303_base_dead and friends. A plausible reading is that a variant shares its
base's vertex and index counts, making the two mutually confusable so that
locality cannot separate them; recorded as untested rather than asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:39:35 +00:00
f18d5919f7 fix(mesh): anchor XBG7 resources by neighbourhood -- inconsistency 125 -> 63, ships render right
anchor_pool_mesh took the FIRST candidate in file order from a container-global
vertex-run scan, so a resource could be handed another resource's block whenever
both shared (stride, vertex count, index count). Both blocks are real geometry and
both pass every quality gate, so only position separates them.

anchor_pool_mesh_near now tries candidates in order of distance from a reference,
and anchor_models_filtered runs two passes: pass 1 anchors first-match to learn
where resources land, pass 2 re-anchors each resource preferring the median anchor
of its +/-2 descriptor neighbours. Too few anchored neighbours -> keep pass 1, so
nothing regresses to guesswork.

  before  decoded 5480/6294  shared 681  inconsistent 125
  after   decoded 5480/6294  shared 681  inconsistent  63

Coverage unchanged, inconsistency halved. e303_wep_01 decodes to 49x23x42 in ALL
containers now, and e106 renders as a destroyer instead of a slab -- its two
shared turrets symmetric at X[-203,-154] and X[154,203]. That resolves the
user-reported "capital ships assemble wrong" for this cause.

The filtered path needed care: models_named (what the viewer uses) dropped
non-wanted resources, which would have left filtered decodes with no
neighbourhood and silently kept the old behaviour. Resources are now collected
regardless of the filter, but only the asked-for ones and their +/-2 neighbours
are decoded in pass 1, so a filtered decode stays proportional to what was asked.

63 cases remain; mesh_consistency_disc stays ignored and now records 63, not 125.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:31:20 +00:00
d660705c47 re: the correct anchor is already a candidate -- locality selects it, monotonicity does not
Enumerating every validating candidate for e303_wep_01 in Stage_S02 gives exactly
two:

  vb = 18403456   span 1600 x 2100 x 4800   <- taken, only because it is first
  vb = 52257440   span   49 x   23 x   42   <- correct, the size all 11 good
                                               containers give

So the scan is not missing anything; the wrong block merely appears earlier in
file order.

Monotonic anchoring is REFUTED as the constraint: recording each resource's
accepted anchor in descriptor order gives 25/47 increasing steps in Stage_S01 and
130/248 in Stage_S02, no better than chance. Locality holds instead -- in
Stage_S02 the descriptor neighbours anchor at 51974668 and 52218424, the correct
candidate is 52257440, and the wrongly-taken block sits at 18403456, two thirds
of the file from its own family.

Proposed rule, needing no new format knowledge: among validating candidates
prefer the one nearest the neighbouring resources' anchors, falling back to
first-match when there is no neighbour yet. It selects 52257440 here.

Not implemented: it moves the anchor for all 6294 resources, so it needs the
before/after measurement (decoded must not fall from 5480, inconsistent should
fall from 125) and then un-ignoring mesh_consistency_disc.

Instrumentation reverted; only docs land here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:16:22 +00:00
7c514e3a57 re: ROOT CAUSE of the silent XBG7 mis-decodes -- the candidate list is container-global
anchor_pool_mesh walks candidates from vertex_run_starts(bytes, data_base,
stride): ONE scan of the whole container per stride, shared by every resource of
that stride, and it accepts the first candidate that validates. Nothing ties the
chosen block to the resource it belongs to, so two resources sharing (stride,
vertex count, index count) are interchangeable to this search.

The wrong block is not distinguishable by quality. Tracing the accept for
e303_wep_01:

  Stage_S01  vb=4600480   span   49 x   23 x   42   passes 0.85 = true
  Stage_S02  vb=18403456  span 1600 x 2100 x 4800   passes 0.85 = true

Both clear the strict winding gate, because the wrongly-taken block IS real
coherent geometry -- another resource's. That rules out a family of fixes: no
threshold, scoring, or best-of-N selection can help, which is exactly why the
previous iteration's attempt changed nothing.

The search space must be constrained instead. Two candidates recorded, ordered by
how much new format knowledge they need: per-resource data extents from the XPR2
resource table, or monotonic anchoring (resource k's vb after resource k-1's) if
data blocks follow descriptor order -- testable against the 556 resources that
already decode consistently.

Instrumentation reverted; only docs land here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:08:38 +00:00
b5a4764567 re: the XBG7 silent mis-decode is in the per-block anchor, not the grouped-pool one
Tried the obvious fix: make anchor_grouped_meshes pick the best-scoring vb0
instead of the first candidate clearing the 0.85 gate. It changed nothing --
still 5480 of 6294 decoded, still 125 inconsistent -- and instrumenting the pivot
loop shows why: for e303_wep_01 that function never runs. The resource has a
single sub-mesh, so it goes through the per-block adjacency path
(anchor_pool_mesh) instead.

Recorded because the grouped-pool anchor is the prominent, well-documented one
and the natural first suspect; the fault is in the other path.

The change is reverted -- untargeted, unproven, and it added a scoring path for
no measured benefit. The idea behind it (several vb0 candidates can clear the
gate, and first-in-scan-order is an arbitrary tiebreak) still applies to whichever
anchor is actually at fault.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:00:37 +00:00
f34280476d test: lock today's format findings into disc-wide guards
Each finding today was originally missed by reasoning from a sample, so these
assert DISC-WIDE invariants rather than one hand-picked file.

  ui_surfaces_disc.rs (3 passing)
    every_t8ad_on_the_disc_decodes            -- all ~19216 surfaces; the old
                                                 256-grid model looked like 96%
    lsta_count_equals_sprites_plus_primitives -- header counts T8aD AND PRMD,
                                                 64/64, which is what made the
                                                 count look unreliable
    ratc_nesting_is_exactly_one_level         -- nested records are leaves; zero
                                                 grandchildren disc-wide

  mesh_consistency_disc.rs (1 ignored, deliberately)
    shared_resources_decode_identically_in_every_container

The mesh test is written as the TARGET state, not a snapshot of the bug: a
resource shared by several containers must decode to the same bounds, which today
fails for 125 of 681 shared resources. Fixing the anchor scan makes it pass;
un-ignoring it is then the last step rather than a rewrite. It only compares
decodes that agree on vertex/triangle counts, so "found different geometry" stays
a separate question from "placed the same geometry differently".

All suites green: 81 lib + the disc guards, with 2 ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:50:20 +00:00
140856b3f9 docs(re): session log for 2026-08-11, and correct the capital-ship INDEX row
The capital-ship row still said placement was the open question. Today's work
says otherwise: placement is sound (hull static-exact, cross-id mounting is 2
pairs across 335 ships) and the user-reported "ships assemble wrong" is an XBG7
mis-decode instead. The row now says that and points at the backlog and the mesh
doc, and keeps the one-way-test problem visible.

SESSION-2026-08-11.md summarises the run for review, leading with the single item
that needs the user's decision (editing save slot 01, deliberately not done) and
separating findings, the two disc defects found, and the process notes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:41:58 +00:00
4097e8503f re: a detector for silent XBG7 mis-decodes -- 125 of 681 shared resources fail it
Following the e106 slab to its cause. e303_wep_01 decodes from 14 containers: in
11 it is a 49x23x42 turret with organic vertices, in Stage_S02/S08/S26 the same
resource -- identical 172 verts and 330 indices -- decodes to 1600x2100x4800 of
axis-aligned box corners with (0,0,1) normals. The anchor scan found a different
buffer that happens to share both counts, so every size-based check passes. That
is the "declined for location, not format" risk succeeding wrongly instead of
declining.

The detector needs no ground truth: a resource shared by several containers must
decode to the same bounds. 681 resources appear in >=2 containers and 125 decode
to different bounds while reporting identical vertex/triangle counts -- a lower
bound, since a resource wrong everywhere is invisible to it. _rou_f401 is
62x25x10 in 16 containers and 4738x3147x4738 in 2; _rou_e011_wep05 gives four
different spans across 8.

Majority vote would resolve 104 of the 125 (14 are exact 50/50 splits) and agrees
with ground truth in the one case that has a render and a runtime capture behind
it. Recorded as a heuristic, not applied: for _rou_e302_base_break the majority
is the LARGER span and nothing yet says which is right. Flag, do not silently
rewrite geometry on a vote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:34:31 +00:00
c9916bcb41 re: CORRECTION -- the e106 slab is a container-dependent mis-decode, not a stray volume
One iteration ago I concluded assemble_ship was drawing a non-renderable
collision volume. That named the wrong cause, and decoding the same resource from
every container that holds it settles it:

  Stage_S01  172 verts 110 tris  49 x 23 x 42        <- a turret, correct
  Stage_S02  172 verts 110 tris  1600 x 2100 x 4800  <- wrong
  Stage_S08  same                1600 x 2100 x 4800  <- wrong
  Stage_S26  same                1600 x 2100 x 4800  <- wrong
  eleven others                  49 x 23 x 42        <- correct

Same resource, same vertex and triangle counts, correct in eleven containers and
wrong in three. So the placement is legitimate (e303_wep_01 is a small shared
turret cross-mounted on e101/e106), the original author's vbase-dedup explanation
of the capture's silence stands, and my "dedup would show one, not zero"
objection does not survive -- at its true size the turret is ordinary geometry.
The defect is in the mesh decoder.

The wider point: the decoder can produce wrong geometry WITHOUT declining. The
XBG7 audit counted 814 honest refusals; this is the other kind, silently 100x too
large. A screen for the signature (bounds exact multiples of 50, span > 1000)
flags 22-32 models each in S02/S03/S08/S26/S27, but it also catches legitimate
e_rou_* composite proxies, so that is a candidate list and not a bug count.

Next: diff the anchor scan's chosen vb0 for this resource between Stage_S01 and
Stage_S02 -- same resource, two outcomes -- and turn whatever distinguishes them
into a post-decode sanity check so a silent mis-decode becomes a decline.

Kept from the previous entry: the test can only fail one way, and cross-id
placement is genuinely narrow (2 pairs across 335 ships).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:25:55 +00:00
c5a5065389 re: the format layer is NOT exonerated -- assemble_ship draws a non-renderable volume
Did the visual the backlog kept naming as the next step, and it overturns the
conclusion. Rendering e106 from the static assembly and from the baked runtime
capture: the capture has 8 placements, the static assembly 11 -- the same 8 plus
e303_wep_01 x2 and a second e106_eng_01. In the render the destroyer sits inside
a white slab that dwarfs it.

That slab is e303_wep_01: 172 verts, 110 tris, bounds X[-1000,600] Y[-1050,1050]
Z[-2400,2400] = 1600 x 2100 x 4800. A real e106 turret (wep_02_01) is 269x179x417
with 772 tris; the bridge is 105x76x305. So it is not a turret -- it reads as a
collision/trigger volume, and whatever it is the game does not draw it: the e106
capture contains no e303_wep_01 at all.

Why it was missed: assemble_ship treats every rou_* composite node as drawable,
the cross-id mount is documented as intended, and the test asserts
count("e303_wep_01") == 2. The capture's silence was explained as vbase dedup,
but dedup would show one instance, not zero -- and the test walks capture parts
looking them up in the static output, so EXTRA static placements cannot fail it.
Same shape as the earlier include_external gap: a test that can only fail one way.

Scope stated carefully: sweeping 335 ships for "ship-scale span with <400 tris"
flags 20 ships and 58 placements over 28 resources, but ONLY the e106 case is
proven (render + capture absence + geometry). Others may be legitimately large
low-poly parts and need the same three checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:15:22 +00:00
5b8b5e0ab1 re: measure the XBG7 declined set, and close off the descriptor shortcut
"A few multi-stream / quantized bodies remain" declined understates it. Across
all 166 resource3d containers: 6294 XBG7 resources, 5480 decoded (87.1%), 814
declined, in 31 of 166 containers. By name the declined set is 492 e* (enemy
craft), 142 f*, 73 n*, 23 eff*, plus *_dead destroyed variants -- not hero
bodies.

Also recorded as a dead end, so nobody repeats it: the descriptor's third word
looks like (streams << 16) | format -- decoded g001..g003 carry 0x00010001 while
declined t170/t180 carry 0x00020004 -- but histogramming it disc-wide puts
decoded AND declined at every value (0x00010001 alone: 4479 decoded, 328
declined). Its low half tracks sub-mesh count. Decodability is not declared in
the descriptor; it is whether the unit-normal anchor scan can locate vb0, which
the code already tests. 229 declined resources carry the most common word[2] with
under 1 KB of data, i.e. small meshes with too little signal to anchor rather
than exotic formats.

No decode gained this iteration; the gap is now quantified and one attractive
wrong turn is closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:03:58 +00:00
d9c69c566c lsta: the count was never unreliable -- it counts sprites AND PRMD primitives
The module said "a few entries disagree with the actual frame count, so we walk
by the T8aD magic instead". They do not disagree. An LSTA is a display list of
inline elements that are either T8aD sprites or PRMD primitives (the flat
coloured quad the UI bundles use to dim a scene), and the count at 0x04 counts
both: across all 64 lists on the disc, count == T8aD + PRMD, with no exceptions.

The six lists that looked wrong (GP_DEBRIEFING_PILOTLOG, GP_MISSION_SELECT, two
language builds each) each hold exactly one primitive, which is the whole of the
off-by-one.

Also measured after the T8aD rectangle-list fix: all 1281 sprite frames decode,
100%.

parse() still returns sprites and skips primitives -- that is the useful
behaviour -- but the docs now say so instead of blaming the header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:52:02 +00:00
d9ffc223bc re: RATC "one level deep" is not a limitation -- measured, plus a second dangling asset
INDEX read as if the parser stopped at one level. Measurement says there is
nothing deeper: 2859 top-level bundles hold 18002 children at depth 1 and ZERO at
depth 2, with no blob failing to parse.

The children that are themselves RATC -- the .rat layout records -- are leaves.
They carry no child list and reference their siblings BY NAME (the sprite they
place, and via "opt " their focused variant): 3311 such leaves, every one
embedding sibling names, and 10144 of 10148 references resolve within the same
bundle. That is the same by-name convention used one level up (a screen config
naming .prt components) and one level up again (the movie table naming
<lang>.pak+<member>).

The 4 unresolved references are a disc defect, not a parse gap: pmbase.rat in
GP_STAGE_CLEAR.pak's four language builds places pmbase.t32, and that sprite
exists nowhere -- not a pak member, not a child of those bundles. Second such
dangling asset after SUBTITLE_S12B.tbl, so a reimplementation should skip a
missing sprite rather than call it a decode failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:44:19 +00:00
ac10d3bd19 t8ad: a surface is a list of sub-rectangles, not a 256 grid -- disc decode 96% -> 100%
The "~15% deferred variants" were not variants. Auditing every T8aD on the disc
gave 19216 surfaces, 18442 decoding (96.0%) and 774 failing in two clusters:
GP_DIALOG strips declaring 524x63 with a "tile count" of 1 or 2 instead of 3, and
small textures in the six *2D language paks whose pixels ran past the end of the
file.

Both fall out of the per-tile header, which is not opaque flags: it is four BE
u32 -- dst X, dst Y, width, height. A 15x18 icon stores a 13x18 rectangle at
(1,0); pdmes010 stores (59,6,256,54) and (315,6,149,54), the second beginning
exactly 16 + 256*54*4 bytes after the first. So 0x1c is a RECTANGLE COUNT and the
256-grid reading was an accident of most surfaces being stored as full-width
bands.

Parser rewritten to that model, still refusing to guess: a rectangle must fit the
declared surface and its pixels must fit the file, else None. Disc decode is now
19216/19216 = 100.00%.

Two test fixtures were built to the old model and are corrected rather than
worked around. lsta's t8ad_frame wrote NO offset-table entry, so the decoder read
"pixels" from inside the header -- the test passed only because it checked
dimensions alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:37:21 +00:00
9228255bfa re: validate the screen layout against the running game -- eight rows, +/-2 px
Booted to the ARSENAL and detected the eight teal category chips by colour, then
compared their row centres against the eight prbtn1..8.rat placements parsed from
eng\prmain_scr.prt. All eight fit

    screen_Y = placement_Y + pivotY(14) + chrome(45)

with residuals -1,-1,-1,-2,0,-2,-1,-2 px. The spacings are the real signature:
predicted 54,56,55,54,56,55,55 against observed 54,56,54,56,54,56,54 -- an
irregular alternating pattern, not a round number that could match by luck. The
one free parameter is the 45 px emulator window chrome, which is not part of the
game, and the residual is centroid measurement noise.

Confirms three things together: the declaration table is the element set, the
placement region gives real screen coordinates, and the pivot composes additively
(pivotY 14 = half the 28 px chip, so placement is top-left as documented).

Explicitly NOT confirmed: the max-dwell rule for animated elements. These buttons
are static -- every keyframe identical -- which is exactly what makes them a clean
ruler. That rule needs an element captured mid-slide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:25:18 +00:00
35e2d1067f re: placement X/Y are SIGNED, the tail word is time, and the resting pose is max-dwell
Dumping the remaining Arsenal components turned up placements reading as
4294966780 and 4294966856 -- negative as i32 (-516, -440). X and Y are signed;
off-screen animation starts are negative, and read as unsigned a reimplementation
would draw them four billion pixels off.

The raw region then showed the rest of it. The trailing word is a TIME, and a
group is an in -> hold -> out animation: prselect_win1 runs
t=4:-516 -> 6:-71 -> 7:81 -> 8:127 -> 23:134 -> 24:134 -> 25:127 -> 27:81 ->
31:-71 -> 1:-516. So NEITHER the first nor the last keyframe is where the element
sits -- both are off-screen. The resting position is the max-dwell keyframe,
(127,155)..(134,155) here, which is where the weapon-list panel actually appears.

This corrects my own note from earlier today, which reported "final=" positions.
Static elements were unaffected (the pause menu's buttons have identical
keyframes) but animated ones were misread. screen_layout.rs now reports the
max-dwell pose plus the full timeline, and both captures are regenerated.

Also seen while dumping: kind 0x2, and a .sbo child (scrollbar) parented to the
list window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:14:36 +00:00
de357ffdc9 re: the screen parse generalises to the ARSENAL, plus kind 0x4 and the component model
examples/screen_layout.rs dumps a bundle's declaration table and placement region
together. It reproduces the tutorial pause menu exactly and reads the ARSENAL the
same way -- 23 elements that match the running game: eight buttons prbtn1..8.rat
at X=242 evenly spaced (the config declares WEAPON_CATEGORIES = 8, and eight
categories are what the Arsenal shows), prexp3.t32 declared SEVEN times at X=726
34px apart (the DATA SHEET rows), prexp1 sliding (726,143)->(1286,143) with
prexp1a parented to it, and prmsg at (151,645).

Two additions to the format:

  kind = 0x4 marks a REPEATED INSTANCE of a sprite -- prexp3.t32 appears once
  with 0x0 then six times with 0x4, each with its own placement. So the element
  name is not a key; the declaration index is.

  A screen composes from named .prt components. GP_HANGAR_ARSENAL.pak has 510
  RATC entries because its config names components (Menu = prmain_scr.prt, etc.)
  and they resolve under the config's own PATH prefix: prmain_scr.prt is absent,
  eng\prmain_scr.prt is present -- the same <lang>+<member> convention the movie
  table uses. A sub-component reads identically: psselect_win1 declares 4
  elements, three parented to element 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:06:00 +00:00
f65387143e re: the UI screen gap is closed -- the bundle also carries every element's placement
Immediately after the declaration table the RATC bundle holds a placement region,
one group per element: a header of (u32 element index, u32 keyframe count)
followed by that many 40-byte keyframes carrying scale/tint/X/Y. The element
index is stated in the header, so the correspondence is read, not inferred from
order.

Tutorial pause bundle: 11 groups for 11 elements, every header index and count
matching the blocks present. The values are self-evidently right -- three menu
buttons at X=546 exactly 70 px apart (288/358/428), title (540,119), message
(451,545), and the eff* sprites carrying multi-position fly-ins.

Cross-checked: pgp_ttrl_btn10.rat places its own sprite at (546,288), identical
to its inline group, so the inline region is the same placement data and it
covers the eff*/deli*/msg elements that have no record of their own.

So a screen is fully reconstructible from its bundle alone: element list and
order from the declaration table, placement and animation from this region,
plus the sprites, with "opt " supplying focused states. This retires the caveat
added earlier today that only the order was known.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:58:21 +00:00
dd8770826b docs: INDEX — retire the stale 'loop1.rat not decoded' note
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:50:39 +00:00
8814df843b re: the RATC declaration table IS the screen's draw list, and +32 is a parent index
The doc's open gap was "where does the screen's draw list live" -- the eff/deli/msg
sprites have no .rat and loop1.rat turned out to be an animation. It is the
bundle's own declaration table, which is NOT the child listing: children are
grouped by type (all .t32, then all .rat) while the declaration table names
ELEMENTS in a plausible back-to-front order and contains exactly the missing set.
For the in-mission pause menu it lists 23 elements ending with loop1.rat, and it
omits the focused button variants -- those are reached via each base record's
"opt " link -- so it is the screen's element set, not an inventory.

Also corrects the entry layout: it is FIVE words after the name, not "u32 x4
flags", and word +32 is a PARENT ELEMENT INDEX (0xffffffff = none). pgpeff02a
carries 3 and element 3 is pgpeff02; pgpeff03a carries 5 and element 5 is
pgpeff03. Verified on two independent language builds, no out-of-range value in
any bundle, and the tutorial bundle (no "a" variants) is 0xffffffff throughout.

Bounded honestly: what is drawn and in what order is now answered; WHERE the
eff/deli/msg sprites land is not -- the declaration entry has a pivot but no X/Y.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:50:13 +00:00
8b7c9da067 re: clear mirror handling and the exhaust cones -- the viewer bug's static avenue is exhausted
Mirror handling: the concern was that apply() bakes R*(S*v)+T while the viewer
decides winding from det(m) alone and rotates normals by m alone, so a mirror
encoded as negative SCALE would reflect geometry without flipping winding. It
never happens -- across 1485 assembled parts in all 22 containers there are 22
mirrored parts, every one with det(m) < 0, and ZERO negative or non-uniform
scales. apply_twin_mirrors writes the reflection into m, so the flip always
fires and ignoring s for normals is harmless.

Exhaust cones: the only geometry the viewer invents, drawn at GN_Jet/GN_SJet
frames because the real engine geometry is recessed. Across 335 assembled ships,
192 with exhaust frames, not one cone sits outside its hull bounding box (10% of
span tolerance). Caveat kept in the doc: inside-the-box does not prove a cone is
correctly oriented or sized -- it rules out the reported symptom, no more.

Every mechanism the diagnosis proposed is now eliminated (include_external,
duplicate names, mirrors, cone placement). The visual comparison is no longer
the next step but the only remaining one, and if it agrees the original report
needs re-grounding against a specific ship and expectation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:41:20 +00:00
a7905d0f6b re: kill the duplicate-resource-name hypothesis for the viewer bug, and reconcile 22 vs 16 stages
build_ship_model resolves each placement with find(|m| m.name == p.resource) --
first match wins -- so a repeated resource name inside a container would draw
the wrong geometry. It cannot happen: decoding every XBG7 resource in all 22
stage containers gives 4603 resources and ZERO repeated names. Per ship it is
tighter still: e106 wants 9 distinct names and decodes exactly 9 models for 11
placements, e105 9 for 9, f105 5 for 6.

Two of the three remaining candidates for the user-reported viewer bug are now
gone (this and include_external), leaving mirror handling and the exhaust cones,
plus the untried visual comparison.

The container sweep also reconciles a number I flagged earlier: the disc has 22
stage CONTAINERS -- Stage_S01..S16 plus Stage_S24..S29 -- i.e. 16 story stages
plus 6 extra. The save screen's 16 STAGE sprites and the notes' "22 stages" are
both right and count different things; neither gets edited to match the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:31:18 +00:00
671891f9bf re: XPR2 channel order confirmed against the running game -- the second "dynamic unknown" closed
INDEX carried XPR2 as "colours unverified (dynamic item)". The failure mode that
matters is a swapped red/blue channel: invisible on greys, invisible without
ground truth, and it poisons every asset silently.

Method: pick an asset that is strongly asymmetric in hue and has known in-game
appearance. DeltaSaber_T.xpr (1024x1024 Dxt1, 11 mips) decodes to white/grey
panels with orange-rust accents; the game renders the same hull in the ARSENAL
scene.

  decoded texture : 1073 warm px, 0 cool, median saturated hue 23.3 deg
  in-game render  :  916 warm px, 124 cool, median saturated hue  9.3 deg

Both orange/red; a red<->blue swap would put the texture at ~200 deg. The cool
pixels are the blue UI behind the model and the 14 deg offset is the hangar's
red key light -- neither can move a hue by 180.

Stated as a bounded result, not "colours are correct": a hue-family comparison
cannot see a gamma/sRGB error, premultiplied alpha, or a per-channel scale, so
exact fidelity stays open and needs a flat unlit known-value surface. Cubemap
face ordering untouched. BG_Earth.xpr (A8R8G8B8 cubemap face) decoding to a
coherent nebula is corroboration for the de-tile step, not proof.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:21:55 +00:00
38f58ea559 re: the movie/subtitle/voice link is solved statically -- INDEX called it a dynamic unknown
INDEX carried IXUD as "movie<->track link unknown (dynamic item)". It is a
config record on the disc: tables.pak schema 0x067025b9, whose keys match the
movie game-part symbols in the executable (MOVIE / TELOP / SUBTITLE /
VOICETRACK, plus SUBTITLE_FONT / SUBTITLE_Y / SUBTITLE_HEIGHT next to
silph::GamePart_Movie::Impl::OnPrepare).

101 movies mapped: 94 with a subtitle, 83 with a voice track, 21 with a telop.
Every subtitle reference was looked up in dat/movie/<lang>.pak and 93 of 94
resolve -- SUBTITLE_S12B.tbl resolves in NONE of the six languages, a dangling
reference on the retail disc rather than a decode failure.

Naming is SUBTITLE_<base>.tbl and VOICE_<base>, with the staff roll and five
supply movies (VOICE_D_450..454) as documented exceptions, so a reimplementation
can resolve by convention and fall back to the table for those six.

Deliberately NOT published: the record's ~104 script ids paired to movies.
Positional pairing drifts by three -- visible at the tail, where
S24/S27_SUPPLY_ACROPOLIS would have to map to hokyu_LS_s24A/s27A and do not --
because the IDXD pool dedupes repeated values and an id whose movie was already
named contributes no token. The CSV is keyed by movie file instead.

For the intro-movie work: ADV.wmv has VOICE_ADV and no subtitle, no telop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:10:08 +00:00
bb9e75fe6a re: the title-menu screens read the auto-save (slot 01), and the high score is computed not stored
Two results, both from a safe probe.

Slot 03 was given SHAB[0] = (a=1, b=12345, c=60000) -- a one-minute best time --
and MISSION SELECT still showed 4612 P EASY / 05:24.77 EASY, i.e. slot 01's
numbers. So the title-menu screens read the AUTO-SAVE, and any probe written to
a throwaway slot is invisible there. The previous SHAB identification is
unaffected: it matched slot 01's own bytes, which is what the screen renders.

The consequence is a decision, not a task: mapping the difficulty enum (a) or
testing b needs slot 01 itself edited -- the only save with real progress. Left
undone deliberately and flagged in the doc; a full content backup exists and
slot 01's bytes are archived, so it is recoverable, but it is the user's call.

Also: High Score 4612 is stored NOWHERE. It is absent from both .gpd profile
files, all three saves (compressed and inflated) and xconfig.settings, while the
best time 324773 is present in slot 01 alone. The score is computed at display
time, which is why SHAB[0].b = 4101 does not equal it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:00:16 +00:00
df45a1986b re: SHAB is the per-stage record table -- record 0 is Stage 01, and c is its best time
EXTRAS -> MISSION SELECT renders per-stage records and they match the table.
Stage 01 shows "High Score 4612 P EASY / Best Time 05:24.77 EASY" while SHAB[0]
is (2, 4101, 324773, FILETIME): 324773 ms IS 5m24.773s, the displayed best time.
Sixteen records, sixteen story stages -> the table is indexed by stage, and
record 0 is Stage 01, the only stage this save has cleared.

  a = 2       difficulty of the record (both entries tagged EASY)
  b = 4101    NOT the displayed high score 4612; equals GHAD +24/+28
  c = 324773  best clear time in ms -- confirmed against the screen
  d,e         FILETIME

This also explains the probe that filled SHAB[1] and changed nothing: the table
holds stage RECORDS, not progress, and Stage 02 has never been cleared so its
record is legitimately empty. Promotes the earlier "per-stage result record"
guess to confirmed for the layout.

Route: title -> EXTRAS -> MISSION SELECT. Only cleared stages are selectable (16
d-pad presses never left Stage 01) and the screen has its own difficulty
selector (Y).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 20:50:06 +00:00
98b3cf56ac re: name the save read path, and locate the save state at game_object+304
Disassembling around the serializer names its mirror and the whole read side:
deserializer 0x822C0380 (reads GDAA + phase string), GHAD reader 0x822BF858
(called with save+8, pairs with the writer 0x822BF678), SHAB reader 0x822BFD60
(save+136, count 16).

The GHAD reader is a plain unrolled byte copy with no clamping or validation
anywhere, which is why hand-written field values survive into the object exactly
as written.

The deserializer's only caller builds its argument as
lwz r11,4(r30) / lwz r11,0(r11) / addi r4,r11,304, so the save state lives at
+304 of a game object and every field now has a fixed absolute offset there --
Points at +336, clear ratio at +320, the develop blob at +380, the SHAB table at
+440. That is what a live-RAM read of the save state needs.

Recorded as a negative too: searching those absolute offsets does NOT find the
panel's populate code. lwz rX,336(rY) has 193 matches, and the windows holding
several right offsets at once are a vtable thunk table (slots 308-340) and a
float-parameter block. The stage/difficulty fields need the screen-side path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 20:24:47 +00:00
bc42c0970e re: the save screen's key table is compiled into the executable -- a 4th difficulty, 3 states, 16 stages
Static, no emulator. The LOAD/SAVE screen's config keys sit in .rdata as a
pointer array at 0x820a0074, identified as config keys (not loose strings) by
the Arsenal's own key list sitting a few hundred bytes earlier and matching its
pak record exactly.

  - GAME_VERY_HARD exists alongside EASY/NORMAL/HARD, while the stage tables
    only carry BonusPoint_EASY/NORMAL/HARD -- a fourth, unpriced tier.
  - Game Status is a 3-valued enum: STATE_STAND_BY (our save's "At Standby"),
    STATE_STAGE_CLEAR, STATE_GAME_CLEAR.
  - Exactly 16 STAGE sprite keys, matching weapon.tbl's stage01..16 (plus 6
    tutorials and 6 challenges). So 16 is the story-stage count and the "22
    stages" figure used elsewhere counts a different set -- flagged, not
    silently changed.

Also recovered: the panel's field names and positions (Points, FlightTime
%03d:%02d:%02d, ClearTimes, CompletionRate, RatioOfClear, Index, Label, Date,
BrokenData).

It does NOT name the stage/difficulty save fields, and it explains why the probe
sweep could not: the screen picks a sprite key BY INDEX through a config lookup,
so there is no lis/ori immediate to xref back to the selecting code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 20:13:01 +00:00
2da9b8a9b8 re: the whole GHAD block is neither stage nor difficulty -- sixteen probe saves
Extends the previous refutation to a sweep. Probed with no effect on STAGE 02 or
Difficulty EASY: every scalar in the GHAD block (+0, +12, +16, +20, +28, +32,
+36 at 1/3/9, +40 u64, +48, +52, +56, +60, +64 raw), SHAB[0].a, and the SHAB
FILL COUNT in both directions -- record 1 filled with a copy of record 0, and
record 0 cleared. The "stage = filled-record count + 1" idea dies with it, and
so does the reading that made SHAB a per-stage result table by that route.

The panel does re-read each slot: slot 02 holds Points 4101 / Clear Ratio 5 %
and displays exactly that while its neighbours show 101 / 6 %.

Left: the phase string, the trailer, or the blob. Recorded caveat -- every save
on disc is genuinely Stage 02 EASY, so "field not found" and "panel does not vary
those two labels per slot" are not yet separated, and another probe round cannot
separate them. The next move is static: find the code that formats STAGE %02d
and read which offset it loads.

savegame_edit.py --set now packs an int into raw_* byte fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 20:01:26 +00:00
eac3435cc7 re: +36/+52/+56 are neither stage nor difficulty -- refuted with eleven probe saves
Those three fields all hold 2, and "difficulty or stage, undecidable from one
save" has been the reading since the format was parsed. Writing saves makes it
decidable and the answer is neither.

boot_menu.sh boots to the title menu WITHOUT loading anything, and LOAD GAME's
slot list renders each slot's Details panel from that slot's payload. Extra
slots can be fabricated (copy the directory plus a gameNN.header with its
UTF-16BE display string and ASCII name patched), so four probes fit in one boot,
read-only.

Probed: +36 at 1/3/9, +52 and +56 at 1/9, and +0, +16, +32, +48, +28,
SHAB[0].a. Every one left the panel at STAGE 02 / EASY / At Standby /
Times Cleared 0.

The negative is meaningful because the panel does read each payload -- slot 02
shows 5% clear ratio against the others' 6%, and Points tracked +24 exactly.
Two controls: patching a slot header to "STAGE09 HARD" changed nothing (the
display is payload-driven, not header text), and the row date follows the
container FILETIME.

Remaining candidates: +12, +20, +40 (u64), +60, +64, or the phase string.

Also here: savegame_edit.py --slot for SHAB records, and boot_menu.sh itself --
nav_probe.sh's boot loads a save, which with probe slots on disc loads a probe,
and a dropped d-pad step there put A on TAKE OFF and spent a boot loading a
mission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:37:06 +00:00
6e0fbe52a6 docs: retire the last three sentences that predate the solved index space
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:01:43 +00:00
841156a95d re: the develop blob's index space is SOLVED -- it follows strings.tbl, cut items and all
Three hand-written probe saves settled what a mission payout could not.

  all 54 owned  -> every tab fills and the lists WRAP, giving exact counts
                   (GUN 8, BEAM 8, LASER 4, MPM 5, ASM 5, B/R 7, CANNON 6,
                   SPECIAL 6 = 43 displayed) and confirming the display order is
                   weapon.tbl's order item-for-item. It also killed the idea
                   that gate-unmet owned flags are suppressed: Maelstrom Bomb
                   (Stage 6) and Grav Cannon XGS (Stage 12) both rendered at a
                   Stage-2 save.
  only 9 + 39   -> STILETTO BG1 Developed (control) AND TOMAHAWK ALPHA RAIL GUN
                   Developed. The CANNON block starts at 39, not 38.
  only 39 + 48  -> JAMMING SYSTEM Developed. SPECIAL starts at 48, and with six
                   SPECIAL items the array closes exactly at 53, nothing spare.

So the blob is indexed by strings.tbl's item order -- the display order PLUS the
items only the localisation file carries (Adhesive Mine B2A, and three of
Ballista GSH / Designator LH / Smoke Marker Launcher / Bumble UV). They never
appear in the Arsenal but they hold slots, which is the entire reason the tail
looked broken. It also explains the retail save's five "unexplained" owned
entries at 33 and 45/46/47: cut items, shipped flagged owned, never rendered.

That weapon.tbl's No_Equipment..Wep_83 run is ALSO 54 long is a coincidence, and
it cost real time -- the two orders agree only to index 32. Recorded as such.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:01:14 +00:00
dc97dc215d docs: reconcile the 'free items are granted' wording with the 0 P purchase the probe caught
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 18:10:58 +00:00
f5f95be0a1 re: saves can be written back, which settles what the develop blob's 4s mean and refutes the tail
The container's derived fields turned out to be reproducible -- length+10 at
+0x30, payload length at +0x8c, adler32(payload) at +0x8e, everything else
copied -- and savegame_edit.py re-wraps a real save BYTE-IDENTICALLY, which is
the check that those three are the only ones. A hand-written save then loaded.

That replaced a blocked experiment (the tail question needed a mission payout,
and none of the currently developable items even sit in the disputed range) with
a direct one: write the blob, read the Arsenal.

  - controls: 4 at index 9 -> STILETTO BG1 Developed, 21 -> FALCON 9AM
    Developed. A hand-written 4 reaches the screen.
  - tail: 4 at 33 and 45 left their rows dashed (both on screen, not below the
    fold), and 38 left TOMAHAWK ALPHA RAIL GUN at "0 P" -- not owned. So the
    tail is not the weapon.tbl order continued.
  - clearing the real save's {22,26,39,46,47} cost the Tomahawk its Developed
    status, which puts its flag in that set (39 positionally) -- but a uniform
    +1 fails for SPECIAL, so no shift is asserted. Indices >=32 stay marked.

Two behaviours fell out. The title RE-DERIVES developable state on load and
announces it ("You can now develop Broad Sword ..."), so only the 4s are stored
state and a written 2 is pointless. And a no-cost item is bought for 0 P rather
than granted -- TOMAHAWK at "0 P" is what unowned looks like -- which is the
actual reason items read Developed in a save where nothing was spent.

Slot 03 was restored from its archived original (md5 verified); slots 01/02 were
never touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 18:10:28 +00:00
f88f4f6847 re: the Arsenal develop economy, and the save blob's index space (first 32 confirmed)
GP_HANGAR_ARSENAL.pak's screen config points at weapon.tbl (item ids, in the
8-category display order) and strings.tbl (names, descriptions, and a
"Conditions to obtain" block per item). The id run No_Equipment .. Wep_83 is
exactly 54 long -- the save blob's length -- and all 60 conditions blocks are
extracted to a CSV: gates are stage completion, a predecessor item, or an ace
kill; costs run 3000-350000 P, and 20 items cost nothing once gated (which is
why items the player never bought read as Developed).

Predicting the save state from those conditions -- before looking at the blob --
says exactly six items are developable here, and the blob's six 2s sit on those
six, in weapon.tbl order, at indices 1/5/10/12/27/31. With the four obtained
items and the differential's own two transitions that is twelve concordances
over indices 0-31, nothing fitted. Broad Sword SG1 at index 5 needed scrolling
the GUN list to see, which is the only one the first screenshots missed.

The tail is NOT settled and is marked so: the Tomahawk is weapon.tbl index 38
and the screen shows it Developed, but blob[38] = 0, and the other tail 4s
(33/39/45/46/47) land on items the Arsenal shows as locked -- the SPECIAL tab is
entirely empty. A +1 shift does not repair it either. Settling it needs a second
development in a late category, which needs a mission payout.

Also recorded: IDXD string pools dedupe repeated values, so only the first
record of a table can be read from the token stream -- record 2 shows just its
unique values, record 6 no cost at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:38:45 +00:00
6ebf2ba00a re: capture the Hangar screen — it is a loadout picker, not a craft data sheet
The Hangar lists NOSE / MAIN WEAPON 1-3 with the mounted weapon and a derived
'Gross Weight: Light'. It shows no craft stat fields at all, so the Hangar is
not a route to the unit definition's defaulted fields — the Arsenal DATA SHEET
(weapons only) and the live-RAM route remain the only two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:19:21 +00:00
f0ceda1c6f re: developing one Arsenal weapon separates Points from its twin and names the save blob
A third save, taken in the running game after developing exactly one item
(Light Machine Gun MG I, 4000 P) and declining the mount prompt, moves exactly
three things against the same-state save:

  +24  Points      4101 -> 101   and +28 does NOT move, which separates the
                                 pair one save could not tell apart; the
                                 Details panel then reads Points 101 P, so
                                 +24 is the spendable balance
  +8   clear ratio 5 -> 6        so the ratio counts collection, not stages
  +68  blob[1],[2]  2->4, 0->2   the item bought, and the successor the game
                                 announced as newly developable

One action, two blob transitions, two on-screen events in the same order — which
is what makes 0 locked / 2 developable / 4 developed a reading rather than a
guess, and rules out a plain owned-bitmask (it could not hold the middle state).
Which item each of the 54 indices is stays open: the arsenal id lists in
GP_HANGAR_ARSENAL.pak union to 35, and MG I sits at index 1, not 0.

Also recorded: inside a modal yes/no dialog a 60 ms d-pad tap is ignored (the
list menus accept it), and the develop confirm starts on NO while the mount
prompt right after it starts on YES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:16:57 +00:00
5cc995e864 docs/re: INDEX — reflect the named save fields and the pure-state result 2026-08-11 06:02:31 +00:00
882dbc2ae2 re: a second save names Points, flight time and clear ratio — and shows the payload is pure state
Made a second save in-game (READY ROOM -> SYSTEM -> SAVE GAME -> empty slot) so
the format had a differential to read against. nav_probe.sh drives it: boot to
the READY ROOM, walk a scripted step list, screenshot after every step and stamp
every save file's md5, so the trail says which keypress wrote a save. That stamp
is what caught the first attempt failing -- the save confirm starts on YES,
unlike the load confirm which starts on NO, so the load flow's extra up-press
selected NO and wrote nothing.

Result 1: saving the same loaded state into a new slot produces a BYTE-IDENTICAL
545-byte payload. Only the GDHA header moves, and every byte that moves is either
the container FILETIME or one of the guest-pointer words -- which empirically
confirms those words are uninitialised padding rather than data. So the payload
holds no timestamp, no slot number and no name; a save's identity is entirely in
its content header.

Result 2: the LOAD/SAVE screen's Details panel prints Points 4101 P, Flight Time
000:05:24 and Clear Ratio 5 % for exactly this state, which names GHAD +24
(Points), +4 (flight time in ms -- 324773 ms = 5m24.773s) and +8 (clear ratio %).
Difficulty EASY and STAGE 02 both being 2, and three fields holding 2, is left
undecided on purpose: one save cannot separate them.

Result 3: the 16 SHAB records are not the UI's save slots. The UI has 20, slots
are separate gameNN files, and record 0's FILETIME stayed at 2026-07-23 in a save
written on 2026-08-11 -- so the table is part of the game state, most likely a
per-stage result record (one stage finished, one record filled), which the next
cleared stage will falsify or confirm.

The original save was backed up first and is untouched; the new save went to an
empty slot.
2026-08-11 06:02:21 +00:00
398e8ae0af re: the save file is a GDHA/zlib chunk stream — layout read off the title's own serializer
The whole retail save is 545 bytes: a GDHA container wrapping a zlib payload,
which is a chunk stream — 'GDAA', a length-prefixed game-phase name (GP_BUNK,
one of the title's GP_* screen ids), a 'GHAD' 122-byte progress block, a
count-prefixed table of 16 20-byte save slots, and a trailer.

None of it is guessed. The layout comes from the serializer at 0x822C00E8 and
its callee 0x822BF678: the writer primitive is 0x821885A8(stream, buf, len), the
slot count 16 and the 20-byte stride are literals, and the GHAD block is ten u32,
a u64, four u32, a raw 4-byte field and a raw 54-byte blob = 122 bytes, which is
exactly what the file carries. savegame.py re-serializes the parse and asserts
byte-identity.

Because the struct is written field-by-field with no packing changes, a payload
offset is also the offset in the live save object -- save+8 for GHAD, save+136
for the slot table, and 136+16*20 = 456 is the serializer's very next access.

Confirmed independently: slot 0's trailing two u32 decode as a FILETIME to
2026-07-23 20:07:23 UTC, and the content header's display string (written by the
game) says 07/23/2026 21:08 at UTC+1; the 15 empty slots hold 2006-01-01 rather
than zero.

Field meanings are left  on purpose -- the naming oracle is a diff of two
saves, and all four copies on disk are byte-identical, so a second save has to
be made first. Nothing has been written back to any save.
2026-08-11 05:40:26 +00:00
5c3e3dfe47 re: making turrets targets does not save the escort — the effect is inside the noise
The Stage-02 outcome write-up ended by naming "turrets near the asset must
become targets" as the fix worth ~50% of the escort damage. That was an
inference from a co-presence attribution, not a measurement. Run it and it
does not hold.

Also corrects the run labelling: pilot.py gained the SYLPH_KILL_TURRETS gate
two minutes before mission02 started, so mission02 was already a treatment run,
not a second baseline. Only mission01 (0 of 3968 pilot frames targeting a
turret) is the baseline.

At a common t=428s: baseline 46.9% escort hull, treatment 44.5% and 53.0%. The
two runs of the same arm differ by more than either differs from the baseline,
and the escort still reaches zero at t~590-670s in all three. So the
transferable finding is the power limit: one 430s flight cannot resolve an
effect below ~9 percentage points, and every single-run pilot conclusion,
including this one's, is inside it.

What does reproduce: the assault is scripted (onset 166/167/166s), and the
e007/e010 damage split is 50/50 in all three arms including the one that never
fires at a turret -- so that attribution measures the wave script, not us.

Also records that the viewer's include_external hypothesis in BACKLOG is dead
(it defaults true and is threaded through unchanged).
2026-08-11 05:29:52 +00:00
f4d59c5783 re: Stage 02 is lost at ~11 minutes, and the pilot's survival rule is what guarantees it
First session whose deliverable was the mission's ENDING rather than a
measurement (mission_run.sh, 500 s, hull of every entity at 2 Hz). The ACROPOLIS
is untouched to t=170 s then falls at ~53 HP/s with no let-up, reaching zero at
t=640-720 s — so "no mission completed" is not an artifact of the 240 s
time-boxes, and not of the 600 s cap on a blocking tool call. A longer session
would only watch the loss arrive.

Attributing the damage by co-presence, exactly two classes are ever near the
asset: e007 turrets (8483) and e010 bombers (8334). pilot.py treats turrets as
keep-out zones at 2500 units and never as targets — the rule that made it
survive — so roughly half the escort damage comes from the one class it is
designed to avoid. Survival and the objective are in direct conflict and the
pilot resolves it entirely for survival: WARSHIPS 0000, WARPLANES 0009,
REMAINING OB rising 004 -> 008, our hull untouched at 1500/1500 with 120
missiles spent. That is unspent risk budget, not a good run.

Also corrects launch_mission.sh: a harness-tracked BACKGROUND task does not keep
the display alive (lost 11 s in, at the turn boundary) — the
one-blocking-foreground-call rule stands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:57:43 +00:00
530555de9f re: the weapons are the control — sibling-default inheritance is unit-schema-specific, not engine-wide
Four rules from 21 units invites the coincidence objection, so run the identical
sweep against the Weapon/Shell capture, which has COMPLETE coverage (126
records). It finds no sibling rule at all: the one 100%-agreement candidate has a
single distinct value and is really a constant default. Weapon defaults vary per
record exactly as unit defaults do, so "defaults are computed" is general while
"defaults come from a sibling field" is not.

Size_Y <- Size_X survives, and is now checked at the raw-token level rather than
through the sub-record merge: e105, f105 and f101 each declare Size_X/Size_Z/
Size_Radius and no Size_Y, and each reads back its own Size_X at runtime. The
two two-unit hypotheses are demoted to coincidence-not-excluded.

Also records a negative for planning: Stage 01, the only other reachable stage,
adds four uncaptured units that are variants of already-captured ones, so it
would re-measure rather than test the rules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:09:14 +00:00
69b4a2e569 re: a unit field left unset on disc is not a global constant — Size_Y inherits Size_X
Attacks the 21/110 unit-coverage limit from the cheap side: if a defaulted field
always took one runtime value, the captured units would pin it for all 110. Only
6 of 24 confirmed defaulted fields behave that way. The other 18 vary per unit,
so the default is computed.

Asking which OTHER field of the same unit holds that value — counting only
non-zero cases, and checking the two fields sit at different offsets so the
layout solver cannot be aliasing them — gives four rules. Size_Y <- Size_X is
solid: seven unrelated ships (e105 600, e106 300, e108 80, e201 300, f101 400,
f105 700, f106 200) each omit it on disc and each shows its own Size_X live,
while the two fields differ freely when both are on disc. Size_Radius fits both
min(X,Z) and the median of the three axes and cannot yet be separated;
e010_ADAN_Attacker_S is what rules out the simpler Size_X rule. FCSRange and
DefencePoint rest on two independent units each and ship as HYPOTHESIS.

Applied across the disc the rules recover 65 (unit, field) values in units that
have never been visited. Falsification test recorded: load any uncaptured stage
and compare one predicted value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:44:24 +00:00
ca500c171e ship: index-less brg/eng/sld parts never matched their GN frame — 34 ships assembled without a bridge
Tier 3 matched a part to its hardpoint by trailing index, so `e105_brg`
compared "01" == "" against GN_Bridge_01 and fell through silently. The runtime
capture is what exposed it: the game draws the bridge and places it at
[0, 70, -1850] rel e105_bdy_01, and assemble_ship emitted nothing there.

With no index to match on, take the lowest-numbered frame of the category.
Diffing assemble_ship part counts across every container: 34 (stage, ship)
entries gain parts — e102 +2 (bridge and engine), e104 +1, e105 +1, Stages
02-29. ship_audit is unchanged, so nothing regressed, and the capture now agrees
to dT 0.03 / dR 0.000.

Also fixes the diff itself: correlate_frames compared static against a rotation
sampled from the first block, which can belong to another INSTANCE of the class.
Scoped to the position-agreeing cluster, e105_eng_01 goes 1.711 -> 0.000 and
both e106 nacelles to 0.000. The one remaining rotation delta (e106_wep_02_01,
0.134) is a turret whose rotation varies by 0.182 between blocks that agree on
its position — the runtime disagrees with itself more than with the assembler.
The new rotVar column makes that distinction visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:16:06 +00:00
3d9f21f030 re: control the range, segment the frames — f105/e105/e106 all match static assembly
A capture at controlled range (ship_capture_close.sh: lock a capital ship, close
on it, F10 per range band) finally draws capital-ship hulls at full detail. Two
correctness fixes were needed before the numbers meant anything:

* one F10 log is ~14 frames with no delimiter, and WV_ref^-1 . WV_p only cancels
  the camera within one frame — segment_frames splits on vertex-buffer
  recurrence, and correlate_frames cross-checks the blocks against each other
  instead of trusting a single shot;
* aggregate by consensus, not median: a stage holds several ships of one class
  sharing vertex buffers, so a block can mix two instances.

Result: f105, e105 and e106 reproduce assemble_ship to <=0.43 units in
translation and 0.000 in rotation for every part that does not move. The e106
rules generalise, and the viewer bug report now points at the viewer. Narrow
leftovers: e105_brg is missing from assemble_ship, e105_eng_01 rotation differs
by 1.711.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 18:19:26 +00:00
1d4b35df0f re: the Stage-02 capture drew no capital ship at all — invert the match, then control range
Inverting the capture↔part question (invert_capture over one container,
vcount_index over all 166) identifies every large draw in the 2026-07-31
capture: the player's own DeltaSaber (10891 verts), its weapon packs, the
backdrop and particles. Of f101/e105/e106 only 1-3 of 15-37 resources have a
drawn vcount, each a 44-225-vertex far-LOD/effect piece whose count collides
with dozens of unrelated resources.

So the zero-correlation was not an LOD-list gap, not over-strict position
validation and not a different draw path: the ships were too far away to be
drawn. approach_capture.py flies at a locked capital ship and presses F10 per
range band, stamping each capture with its distance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 18:04:58 +00:00
Claude (auto-RE)
bbfeb1c387 re: ship-placement generalisation — static audit + first Stage-02 F10 capture (WIP)
Working the BACKLOG item "capital ships assemble wrong in the viewer".

- ship_audit over all 22 stage containers: only ONE outlier ship
  (f002_bdy_05, Stage_S03/S27, dist 6540 vs cluster spread 1071), so static
  assembly is not grossly broken class-wide. Recurring MULTIKEY joint tracks on
  f104/f105/f106/e102 are the standing hypothesis for class-specific error —
  e106, the one validated ship, has none.
- new tools/re-capture/ship_capture_session.sh: one blocking session that boots
  Stage 02 and fires N F10 ship-captures with screenshots. Boot to in-flight was
  24 s; 3 of 5 presses produced logs (2964/3111/3668 draws).
- NEGATIVE, unexplained: correlate_capture matched ZERO parts for f101/f105/
  f106/e105. Documented with the collected facts and the next step (invert the
  match: largest capture vcounts -> which decoded part has that count).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:16:20 +00:00
claude-re
695351dfc4 docs/re: mark the 'targeting is automatic' section superseded
It concluded no target-select input exists because no single press cycled a
target; select is A pressed twice. Kept with a correction banner rather than
deleted — a probe that never performs the action will 'prove' the action does
not exist, which is the reusable warning.
2026-07-30 20:48:15 +00:00
claude-re
4821ba7fea pilot: target select is A pressed TWICE — from the HUD tutorial, not from probing
HEADS-UP DISPLAY tutorial, verbatim: 'Press A twice to target the enemy closest
to the center of the screen.' A double tap, which is exactly why every button
sweep in flight-controls-runtime.md found nothing and why I concluded targeting
was automatic — each sweep tapped once. It also explains the missiles:
GuidanceType 5 guides to the GAME's selection and the loop had never made one,
so 98 launches guided to nothing.

Wired in: double-tap A when the committed contact is already within 14 deg of
the nose, so the game's choice and ours are the same object. One run: 8 kills
from 66 missiles (12% per missile) against the previous 9 from 101 (8.9%). The
absolute count is inside run variance and the efficiency gain is one sample, so
neither is claimed as decisive — it needs repeat runs.

Also documents that expository tutorials self-advance while interactive ones
stall (BASIC CONTROLS waits forever on 'Go to the box'), and that captions need
cropping across many frames because they type out.
2026-07-30 20:47:54 +00:00
claude-re
58f421d896 re-capture: read the ADVANCED CONTROLS tutorial; its moves regress the pilot, so they ship off
tutorial_capture.sh plays a tutorial and photographs what it teaches. ADVANCED
CONTROLS states three mechanics the key-config screen only named:
  B + LS            Side Roll / 180 Degree Turn / Level Off
  B + A together    face the target (snap turn)
  LT + RT together  'sets your fighter's speed to that of the target ... works
                    well when you are trying to get behind an enemy. Once
                    behind an enemy, this also helps you attack them.'

Wired both usable ones in and measured, one run each, everything else equal:
  commitment only ............ 101 missiles, 364 fire frames,  9 kills
  + match(4500) + snap-face ....  9 missiles,  28 fire frames,  0 kills
  + match(1200) + snap-face ...  57 missiles, 225 fire frames,  2 kills

So both are a net regression as applied, and both now default to OFF. Matching a
target's speed while still 5 km behind means never closing (the pilot sat at
272 u/s all run) — the tutorial scopes it to being already in the saddle. The
B+A snap turn reorients mid-pursuit and destroys the dwell commitment buys.

The code and thresholds stay so a future session can re-enable and A/B them over
SEVERAL runs; one run per config is inside this stage's spawn variance.
2026-07-30 19:50:25 +00:00
claude-re
9f41fe08e9 pilot: target commitment takes kills from 2 to 9; and the target pointer does not exist
The loop re-scored every contact every tick, so the nose chased whichever
fighter scored best that instant and aim error wandered 10-40 deg through a
pass. A missile lock is time-on-target, so constant switching is the one thing
guaranteed to prevent a kill. Commit to a contact until it dies, passes 6000,
sits >90 deg off the nose for 2.5 s, or 14 s elapse.

Same guns, same ballistics, same escort weighting, same missile cadence:
kills 0000 (five gun-only runs) -> 0002 (missiles) -> 0009 (commitment), with
101 missiles vs 98, and the largest hostile-population fall of any run
(134->97). Own hull untouched. The ACROPOLIS still ended at 76.6%, so this is
lethality, not the mission outcome.

Also records a negative result so it is not re-attempted: the selected target is
NOT a raw entity pointer. Three searches came up empty — a +-0x1400 window of
the player object, a full-RAM sweep of every entity-pointer word tapped through
each button (only thread-stack slots churn, which is frame noise), and a delta
tally over all 150 entities of the kind that found the definition pointer at
+0x130. The selection must be a handle, an index, or in a subsystem outside the
entity object.
2026-07-30 19:19:23 +00:00
claude-re
95ac545b2b docs/re: the game's own in-flight action list, off the disc
Decoded dat/GP_OPTIONS.pak (po_keys_btn* sprites): the OPTIONS key-config screen
lists every bindable in-flight action. Two of them change the plan.

'Change Target' exists — so target selection IS an input, and the earlier probe
that swept LB/X/B/A/LS/RS found nothing only because it watched the ammo
counters, which those actions do not touch.

'Padlock Mode Toggle' is a view/aim lock onto the selected target: the aim-dwell
problem solved by a game mechanic rather than by tuning a PD controller, and
presumably how a human holds a contact long enough to lock a missile.

Confirms our measured bindings (Use Nose Weapon = RB, Use Main Weapon = Y,
Accelerate/Decelerate = RT/LT, Radar Map Toggle = d-pad) and adds Special Move,
Maneuver, Resupply and Change Main Weapon (which would reach ASMissile, Power
5000). Also records that CONTROL SETTINGS carries a Control Type preset plus
yaw/pitch/roll sensitivity, so the mapping is not fixed and any stick
calibration is only valid for the save's current profile.

Tutorial menu (GP_TUTORIAL.pak) names the six lessons: BASIC CONTROLS,
HEADS-UP DISPLAY, RADAR, SUPPLY AND SPECIAL MOVES, RADIO ORDERS, ADVANCED
CONTROLS.
2026-07-30 18:54:16 +00:00
claude-re
ab8f5307ff docs/re: in-flight control mapping, and why lethality is an aim-dwell problem
Probed rather than assumed: hold each pad input and photograph the HUD ammo
counters. RB = nose gun (NOSE BM 6000->5956 in 4 s, ~11 rounds/s, HEAT rises),
Y = main mount (MAIN MPM 300->299), d-pad = tactical map overlay, and
LB/X/B/A/LS/RS move neither counter.

No target-cycle input exists. The green TARGET marker is already up with nothing
pressed, so the game selects for us and a guided missile's lock is a
time-on-target mechanic, not a button. That closes the lethality question: it is
not target choice (escort weighting), not ballistics (now from the confirmed
Shell records), and not the mapping — the steering loop simply never holds the
nose on one contact long enough to lock. Aim dwell is the next lever.

Also records two things the reimplementation needs: two weapons with separate
ammo pools and HUD counters, and a HEAT bar that fills while the gun fires
(cap and cool-down not yet measured).
2026-07-30 18:49:16 +00:00
claude-re
76f463b611 pilot: RB fires, Y is the main mount, and guided missiles produce the first kills
fire_probe.sh holds each pad input in flight and photographs the HUD ammo
counters. RB moves NOSE BM 06000 -> 05956 in 4 s (~11 rounds/s, HEAT rises);
Y moves MAIN MPM 00300 -> 00299; nothing else moves either counter. So the
control mapping is measured rather than assumed, and 'we never shoot' is dead:
we shoot and miss.

The disc data says to stop shooting: Shell_TCAF_DeltaSaber_Missile_P is Power
200, GuidanceType 5 (guided), MaximumRange 5000, versus the nose gun's Power 15
unguided — one missile is worth ~14 gun hits on a 500 HP fighter and it steers
itself. Launching them (press Y, release a tick later, >=2 s apart) produced
YOU KILLED: WARPLANES 0002 — the first non-zero kill counter of the series,
against 0000 in all five gun-only runs, with hostiles down 134 -> 104.

Still only 2 kills per 98 missiles (~2%). Likely cause: the game expects a lock
before launch and an unlocked missile is wasted. Reading the lock state out of
RAM is the next step.
2026-07-30 18:40:39 +00:00
claude-re
3f6efadf9e pilot: real ballistics from the solved Shell records — and the metric that says it did not help
Shell_TCAF_DeltaSaber_*_P: Velocity 8000, LifeTime 0.5 s, MaximumRange 4000
(self-consistent: 8000 x 0.5 = 4000), all confirmed. Two things were wrong:
lead computed flight time as d / OUR speed (400-2000 u/s, so every shot was led
4-16x too far), and FIRE_RANGE was 5000 — past where the shells expire.

Both fixed. But the HUD's own kill counters read 0000/0000 at the end of EVERY
run including the nearest-fighter baseline, so the pilot kills nothing in any
configuration and 'fraction of frames firing' was never measuring lethality.
No improvement is claimed.

One clean negative result kept: gating on the target's angular half-size alone
(2.7 deg at 2584 units) is far tighter than the steering loop can hold the nose
— firing collapsed to 1 frame in 2639. Angular size is a floor on the firing
cone, never a cap.

Next: the HUD carries a live ammo count, so holding fire and watching it settles
'we never shoot' vs 'we shoot and miss' in a single run.
2026-07-30 18:16:26 +00:00
claude-re
402985adbf pilot: escort-weighted targeting (DEFEND), plus the capital-ship keep-out it needed
While the asset is losing hull, target what is pressing IT — ranked by distance
to the asset minus credit for closing on it — instead of what is nearest to us.
Trigger and ranking both read the live hull (pos+0x154), so nothing is inferred.
DEFEND engaged 1.9 s after the asset's first hit and held 54% of a 330 s run.

It did NOT measurably save the asset: over the window two runs share, the
policies are equal to within noise (t=239: 23218 vs 23038). Two reasons, both
recorded rather than papered over: the runs are not comparable past that window
(spawn timing differs and the hostile count GREW 134->166 in one, fell 147->118
in the other), and the real bottleneck is lethality — the guns are on for 12% of
combat frames because the target is outside the 9 deg cone the rest of the time.

Also corrects a single-run claim in the previous commit: the asset is NOT
reliably safe for the first ~170 s. A second run had first damage at t=70 s. The
stage does not replay identically; only 'the loss is slow' survives.

Fixes a fatal bug the new mode exposed: DEFEND flies at the asset, which sits
inside the friendly formation, and the first escort run went hull 1500 -> DEAD in
one tick at 2026 units/s, 0.6 s from a friendly destroyer that avoidance thought
it would clear by 365 units — the ship's radius is 2000. Keep-out applied only to
hostile turrets. Every entity above BIG_RADIUS now gets its own radius + 800 of
physical keep-out with braking inside it, whatever its faction.
2026-07-30 17:49:38 +00:00
claude-re
c277e42c92 docs/re: the hull anchor is class-wide — the escort objective is scoreable live
own_state.py found hull = position + 0x154 for the PLAYER. Stage 02 is an
escort and is lost when the ACROPOLIS sinks, so scoring it needs someone
else's hull. Measured over 240 s of Stage 02: at t=0 pos+0x154 equals each
entity's own definition HP across 7 classes and 5 distinct HP values (turret
100, fighter 500, destroyer 10000, cruiser 30000, Acropolis 25000). Nothing
read above its HP; the five that read slightly below were already under fire
when the player launched. It falls with damage (780 events), goes negative at
death, and the object then leaves the heap.

UN_f101_TCAF_Acropolis: HP 25000, radius 1400, measured 25000 -> 23038 over
240 s with the attack starting only at t~170 s (~600 HP/min) — so the earlier
GAME OVER was not a fast loss, it was an undefended one.

Also: REMAINING OB reads 012 while 118 ADAN entities are alive, so it counts
objectives, not hostiles; its address is still unknown.
2026-07-30 17:21:05 +00:00
213 changed files with 40671 additions and 1922 deletions

View File

@@ -12,7 +12,12 @@
ship e106 ref=e106_bdy_04 ship e106 ref=e106_bdy_04
e106_bdy_01 1 0 0 0 1 0 0 0 1 -263.9948 -150.43242 1164.8667 e106_bdy_01 1 0 0 0 1 0 0 0 1 -263.9948 -150.43242 1164.8667
e106_bdy_02 -1 0 0 0 1 0 0 0 1 264.0088 -150.41339 1164.8651 # 2026-08-12: this rotation was diag(-1,1,1) - the X-flip `correlate` bakes in
# when a draw's buffer is the mirror of the decoded one. With distinct anchor
# assignment the twins now decode to their OWN buffers (the container carries
# both halves), so the mirror lives in the data and the placement is plain
# identity. Re-emitting from the capture agrees: `1 0 0 0 1 0 0 0 1 264.04343`.
e106_bdy_02 1 0 0 0 1 0 0 0 1 264.0088 -150.41339 1164.8651
e106_bdy_03 1 0 0 0 1 0 0 0 1 0.0029247368 -34.517372 1075.878 e106_bdy_03 1 0 0 0 1 0 0 0 1 0.0029247368 -34.517372 1075.878
e106_bdy_04 1 0 0 0 1 0 0 0 1 0 0 0 e106_bdy_04 1 0 0 0 1 0 0 0 1 0 0 0
e106_brg_01 1 0 0 0 1 0 0 0 1 -0.005471501 153.31795 -164.03748 e106_brg_01 1 0 0 0 1 0 0 0 1 -0.005471501 153.31795 -164.03748

View File

@@ -0,0 +1,177 @@
# Unit / vessel definition object layout — offset, field, kind.
#
# Read out of the title's own loader, not inferred: `sub_82341A20` builds every
# key as `addi r4, r30, -N` with `r30 = 0x82088f94`, so the field NAME for each
# store is a string in the executable image; pairing each key with the first
# store after its accessor call gives the offset. The accessor for floats is
# `sub_822FC5A8`, which returns 0.0 when the pool has no value for the key.
#
# Verified against live objects dumped from a running Stage 02: 406 field values
# agree with the disc records and 0 disagree, over 11 objects spanning both the
# UNIT and VESSEL schemas (see docs/re/live-unit-definitions.md).
#
# Two conventions matter for a reimplementation:
# * angle fields are DEGREES on disc and RADIANS in this object;
# * a field the disc record omits is left at the accessor's miss value (0.0 for
# floats) unless something later derives it — `Size_Y` takes `Size_X`.
#
# offset kind field
24 str Model
28 str CollisionModel
48 f32 Size_X
52 f32 Size_Y
56 f32 Size_Z
64 f32 Color_R
68 f32 Color_G
72 f32 Color_B
80 f32 Size_Radius
84 f32 HP
88 f32 HQRatio
92 f32 ShieldRatio
96 f32 ThrusterRatio
100 word IsDestructible
104 str NamePlate
112 word MountedShieldGenerator
116 f32 ResistanceToOptics
120 f32 ResistanceToShell
124 f32 ResistanceToExplosion
128 f32 ResistanceToPlayer
132 f32 ResistanceParalyze
136 str HUDMarkerID
156 f32 MinimumVelocity
160 f32 MaximumVelocity
164 f32 CruisingVelocity
168 f32 Acceleration
172 f32 Deceleration
176 f32 AV_PitchPlus_Max
180 f32 AV_PitchPlus_Min
184 f32 AA_PitchPlus_Max
188 f32 AA_PitchPlus_Min
192 f32 AV_PitchMinus_Max
196 f32 AV_PitchMinus_Min
200 f32 AA_PitchMinus_Max
204 f32 AA_PitchMinus_Min
208 f32 AV_Yaw_Max
212 f32 AV_Yaw_Min
216 f32 AA_Yaw_Max
220 f32 AA_Yaw_Min
224 f32 AV_Roll_Max
228 f32 AV_Roll_Min
232 f32 AA_Roll_Max
236 f32 AA_Roll_Min
248 f32 SideThrustVelocity_Max
252 f32 SideThrustAcceleration
256 f32 MaximumBank_Normal
260 f32 YawDragFactor
264 f32 PitchDragFactor
268 f32 RollDragFactor
272 f32 DragFactorThreshold
276 f32 ArterBurner_Vc
280 f32 ReverseThrust_Vc
284 f32 ArterBurner_Acc
288 f32 ReverseThrust_Acc
292 f32 AccPitchFactor
296 f32 DecPitchFactor
300 f32 AV_AxisMode_Max
304 f32 AV_AxisMode_Min
308 f32 AA_AxisMode_Max
312 f32 AA_AxisMode_Min
316 f32 PowerCutConsumeShield
320 f32 PowerCutDeceleration
324 f32 AB_ConsumeShield_Begin
328 f32 AB_ConsumeShield
332 f32 AB_AV_PitchPlus
336 f32 AB_AA_PitchPlus
340 f32 AB_AV_PitchMinus
344 f32 AB_AA_PitchMinus
348 f32 AB_AV_Yaw
352 f32 AB_AA_Yaw
356 f32 AB_AV_Roll
360 f32 AB_AA_Roll
364 word SideRoll
368 f32 SideRoll_Time
372 f32 SideRoll_Length
376 word BarrelRoll
380 f32 BarrelRoll_CountMinimum
384 f32 BarrelRoll_CountMaximum
388 f32 BarrelRoll_Time
392 f32 BarrelRoll_Radius
396 word TurnAttack
400 f32 TurnAttack_CutoffRatio
404 f32 TurnAttack_DoubleRatio
408 f32 CutoffTimeMin
412 f32 CutoffTimeMax
416 f32 TurnAttack_DoubleTimeMin
420 f32 TurnAttack_DoubleTimeMax
424 word TurnAway
428 f32 Turn_AngularVelocity
432 f32 TurnAway_Time_Minimum
436 f32 TurnAway_Time_Maximum
440 word BoostAway
444 f32 BoostAway_Time_Minimum
448 f32 BoostAway_Time_Maximum
452 word HoldPosition
456 f32 HoldPosition_LengthMin
460 f32 HoldPosition_LengthMax
464 f32 HoldPosition_MinimumTime
468 f32 HoldPosition_MaximumTime
472 f32 HoldPosition_SideRatio
476 f32 HoldPosition_BackRatio
480 f32 HoldPosition_CutoffRatio
484 f32 HoldPosition_CancelTime
488 word Slalom
492 f32 Slalom_CutoffRatio
496 f32 Slalom_TurnCount_Min
500 f32 Slalom_TurnCount_Max
504 word Through
508 f32 Through_CutoffRatio
512 f32 Through_AngleMinimum
516 f32 Through_AngleMaximum
520 f32 Through_Time1Max
524 f32 Through_Time1Min
528 f32 Through_Time2Max
532 f32 Through_Time2Min
536 f32 Through_LengthMinimum
540 f32 Through_LengthMaximum
544 word SolidCutoff
548 f32 SolidCutoff_Ratio
552 f32 SolidCutoff_LengthMin
556 f32 SolidCutoff_LengthMax
560 f32 HomingResistAdjustment
564 f32 UsingChaffRatio
568 f32 MaxValue
572 f32 ChargeDelay
576 f32 ChargeDelay_Break
580 f32 ChargeSpeed
584 f32 Delay
588 f32 DelayAdjustment
596 str LowerHPFxModel
604 str ExplosionFxModel
612 str DestroyMotionName
620 str ExplosionMotionName
624 f32 DestroyMotionTime
628 f32 DryMass
632 f32 GrossMass
636 word Thruster
640 word SideThruster
644 word ExplosionSE
648 word JumpIn
652 word JumpOut
656 word ShipEnvironmentSE
660 word LowerHPSE
664 f32 LowerHPThresholdRatio
668 f32 SELength
672 f32 RadarRange
676 f32 FCSRange
680 f32 FiringRange
684 word MountedFCS
692 f32 AttackVesselPoint
696 f32 AttackCraftPoint
700 f32 DefencePoint
736 str ShieldRecoverEffectName
740 str ShieldHitEffectName
744 str JumpIn
748 str JumpOut
752 str FadeIn
756 str FadeOut
760 str Effect_Paralyze

View File

@@ -0,0 +1,55 @@
//! RE probe: the `ACHIEVEMENTS_REQUIREMENTS` config list.
//!
//! `GamePart_Debriefing` (`0x8218CF38`..`0x82191B18`) walks this list after a
//! mission (`sub_8218F9A8`): for each entry it takes the entry's **index** `n`,
//! tests bit `n` of an awarded-mask, and if the bit is clear it evaluates the
//! entry (`0x8218FAB0`) and sets the bit when satisfied. `GamePart_ChallengeMission`
//! then gates each challenge mission on a bit of the same space via its own
//! `REQUIREMENT` key. So this list *is* the achievement/bit numbering.
//!
//! Run: cargo run --release -p sylpheed-formats --example achievements_map -- <disc-root>
use sylpheed_formats::{idxd::IdxdObject, pak::PakArchive};
fn find(h: &[u8], n: &[u8]) -> bool {
h.windows(n.len()).any(|w| w == n)
}
fn main() {
let disc = std::env::args().nth(1).unwrap_or_else(|| {
std::env::var("SYLPHEED_DISC").expect("pass disc root or set SYLPHEED_DISC")
});
let dat = format!("{disc}/dat");
let mut paks: Vec<_> = std::fs::read_dir(&dat)
.expect("dat dir")
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
.collect();
paks.sort();
for p in &paks {
let Ok(arc) = PakArchive::open(p) else { continue };
for (i, e) in arc.entries().iter().enumerate() {
let Ok(b) = arc.read(e) else { continue };
if !find(&b, b"ACHIEVEMENTS_REQUIREMENTS") {
continue;
}
let name = p.file_name().unwrap().to_string_lossy().to_string();
match IdxdObject::parse(&b) {
Ok(o) => {
let t = o.tokens();
println!(
"\n===== {name} entry #{i} schema {:08x} {} tokens =====",
o.schema_hash,
t.len()
);
for (j, tok) in t.iter().enumerate() {
println!(" {j:3} {tok}");
}
}
Err(err) => println!("\n===== {name} entry #{i}: not IDXD ({err}) ====="),
}
}
}
}

View File

@@ -0,0 +1,22 @@
//! Time repeated per-ship decodes of one container, as the viewer does.
use std::collections::HashSet;
use std::time::Instant;
use sylpheed_formats::mesh::Xbg7Model;
use sylpheed_formats::ship::{is_base_part, ship_id_of};
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
let names = sylpheed_formats::mesh::xbg7_resource_names(&bytes);
let ids: Vec<String> = {
let mut v: Vec<String> = names.iter().filter(|n| is_base_part(n))
.filter_map(|n| ship_id_of(n).map(|s| s.to_string())).collect();
v.sort(); v.dedup(); v.truncate(5); v
};
for id in &ids {
let want: HashSet<String> = names.iter()
.filter(|n| ship_id_of(n) == Some(id.as_str())).cloned().collect();
let t = Instant::now();
let got = Xbg7Model::models_named(&bytes, &want, &|| false);
println!("{id}: {} models in {:?}", got.len(), t.elapsed());
}
}

View File

@@ -0,0 +1,123 @@
//! Does a resource have a CLEANER home in its container than the one we picked?
//!
//! After the pad-scoring fix, only two resources on the disc still decode to an
//! index run with degenerate triangles — `e201_bdy_03_m` (2 containers) and
//! `_rou_f402_dead` (9). Degeneracy says the run does not fit the pool, so either
//! the vertex block is wrong or the candidate list never offered the right one.
//! This walks every candidate vertex-run start for the resource's declaration and
//! scores each `(start, pad)` the way the anchor now does — degenerate triangles
//! first, then winding, plus coverage — so the answer is one of:
//! * a strictly cleaner candidate exists (the selection is at fault),
//! * several are equally clean (genuinely ambiguous), or
//! * nothing is clean (the block is not in the candidate list at all).
//!
//! Usage: better_home <container.xpr> <resource-name>
use sylpheed_formats::mesh::{debug_resource_params, debug_vertex_run_starts, Xbg7Model};
fn be16(b: &[u8], at: usize) -> u32 {
((b[at] as u32) << 8) | b[at + 1] as u32
}
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let name = &a[2];
let Some((markers, stride)) = debug_resource_params(&bytes, name) else {
eprintln!("no such XBG7 resource: {name}");
std::process::exit(1);
};
let (vc, ic) = markers[0];
println!("{name}: {} marker(s), first = {vc} verts / {ic} indices, stride {stride}", markers.len());
// Where did the decoder put it?
let ours = Xbg7Model::stage_models(&bytes)
.into_iter()
.find(|m| m.name == *name)
.and_then(|m| m.meshes.first().and_then(|s| s.vbuf_offset));
println!("our anchor: {ours:?}");
let starts = debug_vertex_run_starts(&bytes, stride);
println!("{} candidate vertex-run starts for stride {stride}", starts.len());
// Score every (start, pad): degenerate triangles, winding against the stored
// normals, and whether the run covers the pool exactly.
let mut rows: Vec<(usize, f32, usize, usize, usize, bool)> = Vec::new(); // degen, wind, start, pad, max_idx, covered
for &vb in &starts {
for pad in 0..=3usize {
if vb < ic * 2 + pad {
continue;
}
let ib = vb - ic * 2 - pad;
if ib + ic * 2 > bytes.len() || vb + vc * stride > bytes.len() {
continue;
}
let idx: Vec<u32> = (0..ic).map(|k| be16(&bytes, ib + k * 2)).collect();
let max_idx = *idx.iter().max().unwrap_or(&0) as usize;
if max_idx >= vc {
continue; // out of range — not a candidate at all
}
let pos: Vec<[f32; 3]> = (0..vc)
.map(|v| {
let at = vb + v * stride;
[
f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()),
f32::from_be_bytes(bytes[at + 4..at + 8].try_into().unwrap()),
f32::from_be_bytes(bytes[at + 8..at + 12].try_into().unwrap()),
]
})
.collect();
if pos.iter().any(|p| p.iter().any(|c| !c.is_finite() || c.abs() > 1e6)) {
continue;
}
let mut degen = 0usize;
let (mut agree, mut counted) = (0usize, 0usize);
for t in idx.chunks_exact(3) {
let (x, y, z) = (t[0] as usize, t[1] as usize, t[2] as usize);
if x == y || y == z || x == z {
degen += 1;
continue;
}
// Winding needs normals; use the geometric centroid normal as a
// stand-in so this stays declaration-agnostic: a consistent mesh
// has all faces pointing away from the centroid on a convex-ish
// hull. Weak, so degeneracy leads the sort.
let e1 = [pos[y][0] - pos[x][0], pos[y][1] - pos[x][1], pos[y][2] - pos[x][2]];
let e2 = [pos[z][0] - pos[x][0], pos[z][1] - pos[x][1], pos[z][2] - pos[x][2]];
let f = [
e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2],
e1[0] * e2[1] - e1[1] * e2[0],
];
let cx: [f32; 3] = {
let mut c = [0.0f32; 3];
for p in &pos {
for k in 0..3 {
c[k] += p[k] / pos.len() as f32;
}
}
c
};
let out = [pos[x][0] - cx[0], pos[x][1] - cx[1], pos[x][2] - cx[2]];
counted += 1;
if f[0] * out[0] + f[1] * out[1] + f[2] * out[2] > 0.0 {
agree += 1;
}
}
let w = if counted == 0 { 0.0 } else { agree as f32 / counted as f32 };
rows.push((degen, w.max(1.0 - w), vb, pad, max_idx, max_idx + 1 == vc));
}
}
rows.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.total_cmp(&a.1)));
println!("\n{} in-range candidates; best 12 by (degenerate, winding):", rows.len());
for (d, w, vb, pad, mx, cov) in rows.iter().take(12) {
let mark = if Some(*vb) == ours { " <-- ours" } else { "" };
println!(
" vb 0x{vb:07X} pad {pad} degen {d:>4} wind {w:.3} max_idx {mx}/{} {}{mark}",
vc - 1,
if *cov { "covers" } else { "SHORT" }
);
}
let clean = rows.iter().filter(|r| r.0 == 0 && r.5).count();
println!("\n{clean} candidates are degenerate-free AND cover the pool exactly");
}

View File

@@ -0,0 +1,53 @@
//! Does a resource's DESCRIPTOR carry its bounding box?
//!
//! The last cross-container disagreements are 24-vertex bound boxes swapping
//! identities; no anchoring rule can pin them (see docs). If the descriptor
//! states the box, that is the missing information. This decodes the resource,
//! takes the box its geometry actually spans, and searches the descriptor for
//! those float values.
//!
//! Usage: bounds_in_descriptor <container.xpr> <resource>...
use sylpheed_formats::mesh::{xbg7_descriptor_range, Xbg7Model};
use std::collections::HashSet;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let want: HashSet<String> = a[2..].iter().cloned().collect();
for m in Xbg7Model::models_named(&bytes, &want, &|| false) {
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for s in &m.meshes {
for q in &s.positions {
for k in 0..3 {
lo[k] = lo[k].min(q[k]);
hi[k] = hi[k].max(q[k]);
}
}
}
let Some((d0, d1)) = xbg7_descriptor_range(&bytes, &m.name) else { continue };
println!(
"{} descriptor 0x{d0:x}..0x{d1:x} ({} bytes), box lo{:?} hi{:?}",
m.name,
d1 - d0,
lo.map(|v| v.round()),
hi.map(|v| v.round())
);
// Where in the descriptor does each bound value appear (±0.01)?
let targets: Vec<(&str, f32)> = vec![
("lo.x", lo[0]), ("lo.y", lo[1]), ("lo.z", lo[2]),
("hi.x", hi[0]), ("hi.y", hi[1]), ("hi.z", hi[2]),
];
for (label, v) in targets {
let mut at: Vec<usize> = Vec::new();
let mut o = d0;
while o + 4 <= d1 {
let f = f32::from_be_bytes(bytes[o..o + 4].try_into().unwrap());
if (f - v).abs() <= 0.01 * (1.0 + v.abs()) {
at.push(o - d0);
}
o += 4;
}
println!(" {label:5} {v:10.3} at descriptor offsets {:x?}", &at[..at.len().min(6)]);
}
}
}

View File

@@ -0,0 +1,222 @@
//! Where does a drawn block's INDEX buffer really live?
//!
//! Our XBG7 anchor scan only ever *assumes* the layout `[index buffer][vertex
//! buffer]` with a pad of at most 3 bytes between them (`anchor_pool_mesh`:
//! `ib = vb - idx_count*2 - pad`). Nothing on disc states it, and it is the gate
//! that rejected the capture-proven `e106_eng_02_l` block
//! (docs/re/captures/…): the block's index data was not where the decoder
//! looked. The F10 ship capture was extended on 2026-08-13 to log each draw's
//! index-buffer base, count and min/max index value, so the assumption is now
//! directly checkable:
//!
//! * `vbase - ibase` is the real gap in guest memory, and a stage container is
//! uploaded contiguously (see `shared_vbase_check`), so the same difference
//! holds in the file;
//! * `max index vs vcount` says whether a draw covers its whole vertex pool —
//! the "buffer not covered" miss class is a *sub-range draw* if it does not.
//!
//! Usage:
//! cargo run --release --example capture_ib_truth -- <Stage_SNN.xpr> <capture.log>...
use sylpheed_formats::mesh::{debug_resource_params, xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw};
use std::collections::HashMap;
fn q(v: f32) -> i64 {
(v as f64 * 1e4).round() as i64
}
fn main() {
let a: Vec<String> = std::env::args().collect();
if a.len() < 3 {
eprintln!("usage: capture_ib_truth <container.xpr> <capture.log>...");
std::process::exit(2);
}
let bytes = std::fs::read(&a[1]).expect("container");
// One entry per (log, vbase): the capture already de-dups per placement, and
// a buffer drawn at several transforms has the same index buffer each time.
let mut draws: Vec<CapturedDraw> = Vec::new();
let mut seen = std::collections::HashSet::new();
for log in &a[2..] {
let text = std::fs::read_to_string(log).expect("log");
for d in parse_capture(&text) {
// One entry per (log, vbase, index range): the engine issues SEVERAL
// draws over one vertex buffer, each with its own index sub-range, and
// it is their UNION that describes the block. (Captures taken before
// 2026-08-13 de-dup by (vbase, transform) and so hold only the first
// batch — such a log reads as a mysteriously short draw.)
let k = d.ib.map(|i| (i.ibase, i.icount)).unwrap_or((0, 0));
if d.ib.is_some() && d.pos.len() >= 4 && seen.insert((log.clone(), d.vbase, k)) {
draws.push(d);
}
}
}
eprintln!("{} drawn buffers with an index buffer", draws.len());
// ── Place the drawn buffers in the file: POSITION is f32×3 big-endian at
// vertex offset 0, so the dumped positions are a literal byte pattern.
let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
let mut index: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
let mut o = 0usize;
while o + 12 <= bytes.len() {
let (x, y, z) = (be(o), be(o + 4), be(o + 8));
if x.is_finite() && y.is_finite() && z.is_finite() && x.abs() < 1e6 && y.abs() < 1e6 && z.abs() < 1e6 {
index.entry((q(x), q(y), q(z))).or_default().push(o as u32);
}
o += 4;
}
let mut deltas: HashMap<i64, usize> = HashMap::new();
let mut hits: Vec<(i64, usize, &CapturedDraw)> = Vec::new(); // (delta, file offset, draw)
for d in &draws {
let k = (q(d.pos[0][0]), q(d.pos[0][1]), q(d.pos[0][2]));
for dx in -1..=1i64 {
for dy in -1..=1i64 {
for dz in -1..=1i64 {
let Some(cands) = index.get(&(k.0 + dx, k.1 + dy, k.2 + dz)) else { continue };
for &off in cands {
for stride in (12..=64).step_by(4) {
let ok = (1..4).all(|j| {
let at = off as usize + j * stride;
at + 12 <= bytes.len()
&& (0..3).all(|c| (be(at + c * 4) - d.pos[j][c]).abs() <= 1e-4)
});
if ok {
let delta = d.vbase as i64 - off as i64;
*deltas.entry(delta).or_default() += 1;
hits.push((delta, off as usize, d));
break;
}
}
}
}
}
}
}
let Some((&base_delta, &n)) = deltas.iter().max_by_key(|(_, n)| **n) else {
eprintln!("no draw could be placed in this container");
std::process::exit(1);
};
println!("container load constant: vbase - file_offset = 0x{base_delta:X} ({n} buffers agree)");
// ── Our decoder's view of the same container.
let models = Xbg7Model::stage_models(&bytes);
let mut by_off: HashMap<usize, Vec<(String, usize, usize)>> = HashMap::new();
for m in &models {
for sm in &m.meshes {
if let Some(off) = sm.vbuf_offset {
by_off
.entry(off)
.or_default()
.push((m.name.clone(), sm.positions.len(), sm.indices.len()));
}
}
}
println!("decoded {} resources, {} distinct vertex offsets\n", models.len(), by_off.len());
// Declared-but-not-decoded resources, indexed by their first marker's
// (vertex, index) counts. A drawn buffer our decoder cannot name is the one
// thing a capture can give the residual misses: ground truth for where the
// block actually is. Matching on counts is enough to propose an identity —
// then `debug_try_anchor` at that offset says which gate rejects it.
let decoded_names: std::collections::HashSet<String> =
models.iter().map(|m| m.name.clone()).collect();
let mut undecoded_by_counts: HashMap<(usize, usize), Vec<String>> = HashMap::new();
for n in xbg7_resource_names(&bytes) {
if decoded_names.contains(&n) {
continue;
}
if let Some((markers, _)) = debug_resource_params(&bytes, &n) {
if let Some(&(v, i)) = markers.first() {
undecoded_by_counts.entry((v, i)).or_default().push(n);
}
}
}
// ── The report: one row per drawn BUFFER, aggregating its index batches.
let mut per_buf: HashMap<u32, (usize, Vec<sylpheed_formats::ship_capture::CapturedIndexBuffer>, u32)> =
HashMap::new();
for (delta, voff, d) in &hits {
if *delta != base_delta {
continue;
}
let e = per_buf.entry(d.vbase).or_insert((*voff, Vec::new(), d.vcount));
let ib = d.ib.unwrap();
if !e.1.contains(&ib) {
e.1.push(ib);
}
}
let (mut pad0, mut pad_small, mut pad_off, mut unnamed) = (0usize, 0usize, 0usize, 0usize);
let (mut cover_exact, mut cover_short, mut idx_equal, mut idx_partial) = (0usize, 0usize, 0usize, 0usize);
let mut rows: Vec<(usize, String)> = Vec::new();
for (_, (voff, ibs, vcount)) in per_buf.iter() {
let batches = ibs.len();
let total: u32 = ibs.iter().map(|i| i.icount).sum();
let lo = ibs.iter().map(|i| i.ibase).min().unwrap() as i64 - base_delta;
let hi = ibs.iter().map(|i| i.ibase + i.icount * 2).max().unwrap() as i64 - base_delta;
let umax = ibs.iter().map(|i| i.imax).max().unwrap();
let gap = *voff as i64 - hi; // bytes from the end of the index data to the vertex buffer
let names = by_off.get(voff);
let dec_idx = names
.and_then(|v| v.iter().find(|(_, p, _)| *p as u32 == *vcount).map(|(_, _, i)| *i as u32));
// The decoder's assumption, scored: it expects the whole index buffer at
// `vb - 2*idx_count - pad`, pad ≤ 3.
let dec_pad = dec_idx.map(|i| *voff as i64 - (i as i64) * 2 - lo);
match dec_pad {
Some(0) => pad0 += 1,
Some(p) if (1..=3).contains(&p) => pad_small += 1,
Some(_) => pad_off += 1,
None => unnamed += 1,
}
if umax + 1 == *vcount {
cover_exact += 1;
} else {
cover_short += 1;
}
match dec_idx {
Some(i) if i == total => idx_equal += 1,
Some(_) => idx_partial += 1,
None => {}
}
rows.push((
*voff,
format!(
"vb 0x{:07X} v={:<6} batches {:<3} idx {:<6} span {:<7} gap {:<8} decpad {:<7} cover {:<10} {}",
voff,
vcount,
batches,
total,
hi - lo,
gap,
dec_pad.map(|p| p.to_string()).unwrap_or_else(|| "?".into()),
if umax + 1 == *vcount { "exact".to_string() } else { format!("{}/{}", umax, vcount - 1) },
names
.map(|v| v
.iter()
.map(|(n, p, i)| format!("{n}(v{p},i{i})"))
.collect::<Vec<_>>()
.join(" "))
.unwrap_or_else(|| {
// Nothing of ours sits here — is it a resource that never
// decodes? Propose it by (vertex, index) counts.
undecoded_by_counts
.get(&(*vcount as usize, total as usize))
.map(|v| format!("MISSED? {}", v.join(" ")))
.unwrap_or_else(|| "-".into())
})
),
));
}
rows.sort();
for (_, r) in &rows {
println!("{r}");
}
println!("\nplaced {} drawn buffers in this container", rows.len());
println!(
"DECODER layout assumption — whole index buffer at vb - 2*idx_count - pad: pad 0 {pad0} · pad 1..3 {pad_small} · elsewhere {pad_off} · not decoded here {unnamed}"
);
println!(
"index extent: our idx_count == sum of captured batches for {idx_equal} buffers, differs for {idx_partial}"
);
println!("vertex-pool coverage by the union of batches: exact {cover_exact} · short {cover_short}");
}

View File

@@ -0,0 +1,175 @@
//! Do our decoded triangle indices equal the ones the GPU actually read?
//!
//! `capture_ib_truth` established *where* a block's index buffer lives and that
//! its length matches ours. This asks the stronger question: are the index VALUES
//! the same, in the same order? The capture prints the first 24 indices of every
//! draw batch verbatim (`idx: …`), and a batch's `ibase` locates it inside the
//! block's index buffer — so for each drawn buffer we can line the captured run
//! up against `GameMesh::indices` at the right offset and compare element by
//! element. That tests the whole index path at once: the anchor, the u16
//! big-endian read, the triangle-list interpretation (a strip would disagree
//! immediately), and the sub-mesh carve.
//!
//! Usage:
//! cargo run --release --example capture_index_bytes -- <Stage_SNN.xpr> <capture.log>...
use sylpheed_formats::mesh::Xbg7Model;
use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw};
use std::collections::HashMap;
fn q(v: f32) -> i64 {
(v as f64 * 1e4).round() as i64
}
fn main() {
let a: Vec<String> = std::env::args().collect();
if a.len() < 3 {
eprintln!("usage: capture_index_bytes <container.xpr> <capture.log>...");
std::process::exit(2);
}
let bytes = std::fs::read(&a[1]).expect("container");
let mut draws: Vec<CapturedDraw> = Vec::new();
let mut seen = std::collections::HashSet::new();
for log in &a[2..] {
let text = std::fs::read_to_string(log).expect("log");
for d in parse_capture(&text) {
let k = d.ib.map(|i| (i.ibase, i.icount)).unwrap_or((0, 0));
if d.ib.map_or(false, |i| i.head_len > 0) && d.pos.len() >= 4 && seen.insert((log.clone(), d.vbase, k)) {
draws.push(d);
}
}
}
eprintln!("{} draw batches with an index head", draws.len());
// Place each drawn buffer in the file by its dumped positions (see
// capture_ib_truth for the method) and keep the modal load constant.
let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
let mut index: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
let mut o = 0usize;
while o + 12 <= bytes.len() {
let (x, y, z) = (be(o), be(o + 4), be(o + 8));
if x.is_finite() && y.is_finite() && z.is_finite() && x.abs() < 1e6 && y.abs() < 1e6 && z.abs() < 1e6 {
index.entry((q(x), q(y), q(z))).or_default().push(o as u32);
}
o += 4;
}
let mut deltas: HashMap<i64, usize> = HashMap::new();
let mut hits: Vec<(i64, usize, &CapturedDraw)> = Vec::new();
for d in &draws {
let k = (q(d.pos[0][0]), q(d.pos[0][1]), q(d.pos[0][2]));
for dx in -1..=1i64 {
for dy in -1..=1i64 {
for dz in -1..=1i64 {
let Some(cands) = index.get(&(k.0 + dx, k.1 + dy, k.2 + dz)) else { continue };
for &off in cands {
for stride in (12..=64).step_by(4) {
let ok = (1..4).all(|j| {
let at = off as usize + j * stride;
at + 12 <= bytes.len()
&& (0..3).all(|c| (be(at + c * 4) - d.pos[j][c]).abs() <= 1e-4)
});
if ok {
*deltas.entry(d.vbase as i64 - off as i64).or_default() += 1;
hits.push((d.vbase as i64 - off as i64, off as usize, d));
break;
}
}
}
}
}
}
}
let Some((&base_delta, _)) = deltas.iter().max_by_key(|(_, n)| **n) else {
eprintln!("no draw could be placed in this container");
std::process::exit(1);
};
println!("load constant 0x{base_delta:X}");
// Our decode, indexed by vertex offset. A model may hold several sub-meshes;
// compare against the one whose vertex count matches the draw.
let models = Xbg7Model::stage_models(&bytes);
let mut by_off: HashMap<usize, Vec<(String, usize, Vec<u32>)>> = HashMap::new();
for m in &models {
for sm in &m.meshes {
if let Some(off) = sm.vbuf_offset {
by_off
.entry(off)
.or_default()
.push((m.name.clone(), sm.positions.len(), sm.indices.clone()));
}
}
}
// Where does each buffer's index data START? Take it from the capture, not
// from an assumed pad: `capture_ib_truth` established that a buffer's batches
// tile its index buffer exactly (sum of counts == our idx_count, span ==
// 2*count), so the lowest `ibase` over a buffer's batches IS the block's
// index start. Assuming `vb - 2*len` instead is wrong for the buffers that
// sit at pad 2 and shifts the whole comparison by one element.
let mut ib_start: HashMap<u32, i64> = HashMap::new();
for (delta, _, d) in &hits {
if *delta != base_delta {
continue;
}
let ibase = d.ib.unwrap().ibase as i64;
ib_start.entry(d.vbase).and_modify(|e| *e = (*e).min(ibase)).or_insert(ibase);
}
let (mut agree, mut disagree, mut unmatched, mut nooverlap) = (0usize, 0usize, 0usize, 0usize);
let mut bad: Vec<String> = Vec::new();
let mut checked_elems = 0usize;
let mut done: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new();
for (delta, voff, d) in &hits {
if *delta != base_delta {
continue;
}
let ib = d.ib.unwrap();
if !done.insert((d.vbase, ib.ibase)) {
continue;
}
let Some(cands) = by_off.get(voff) else {
unmatched += 1;
continue;
};
let Some((name, _, ours)) = cands.iter().find(|(_, p, _)| *p as u32 == d.vcount) else {
unmatched += 1;
continue;
};
// Element 0 of our index list is the block's index start, as observed.
let ours_start = ib_start[&d.vbase] - base_delta;
let batch_off = ib.ibase as i64 - base_delta - ours_start;
if batch_off < 0 || batch_off % 2 != 0 {
nooverlap += 1;
continue;
}
let first = (batch_off / 2) as usize;
let n = (ib.head_len as usize).min(ours.len().saturating_sub(first));
if n == 0 {
nooverlap += 1;
continue;
}
let mism = (0..n).find(|&k| ours[first + k] != ib.head[k]);
checked_elems += n;
match mism {
None => agree += 1,
Some(k) => {
disagree += 1;
if bad.len() < 8 {
bad.push(format!(
"{name} vb 0x{:07X} batch@{first}: ours {:?} != captured {:?} (first differs at {k})",
voff,
&ours[first..first + n.min(12)],
&ib.head[..n.min(12)]
));
}
}
}
}
println!(
"index runs compared: {agree} identical · {disagree} differing · {unmatched} no decoded resource · {nooverlap} batch outside our buffer"
);
println!("{checked_elems} index elements checked against the GPU");
for b in &bad {
println!(" MISMATCH {b}");
}
}

View File

@@ -0,0 +1,112 @@
//! Which container did each captured draw come from, and where in it?
//!
//! Extends the single-container `--map` check in `shared_vbase_check` to a whole
//! `resource3d` directory. For each container it indexes every 4-byte-aligned
//! position triple, looks up each draw's first dumped position, confirms the run
//! at a fixed stride, and reports the modal `vbase offset`. A container the
//! engine loaded shows one dominant constant; an unrelated one shows noise.
//!
//! The output is capture-named ground truth for anchors far beyond the one ship
//! `Stage_S01` gave us.
//!
//! Usage: capture_truth_scan <resource3d_dir> <capture.log>...
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog, CapturedDraw};
use std::collections::HashMap;
fn q(v: f32) -> i64 {
(v as f64 * 1e4).round() as i64
}
fn main() {
let a: Vec<String> = std::env::args().collect();
let dir = &a[1];
let mut draws: Vec<CapturedDraw> = Vec::new();
let mut seen = std::collections::HashSet::new();
for log in &a[2..] {
let text = std::fs::read_to_string(log).expect("log");
let mut d = parse_capture(&text);
if d.is_empty() {
d = parse_drawlog(&text);
}
for x in d {
// One entry per buffer; the logs are already deduped per transform.
if x.pos.len() >= 8 && x.vcount >= 20 && seen.insert((log.clone(), x.vbase)) {
draws.push(x);
}
}
}
eprintln!("{} distinct (log, vbase) draws to place", draws.len());
let mut files: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let mut placed = 0usize;
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
// Index quantised position triples. Junk floats (NaN/huge) are skipped,
// which prunes most of a texture-heavy container.
let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
let mut index: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
let mut o = 0usize;
while o + 12 <= bytes.len() {
let (x, y, z) = (be(o), be(o + 4), be(o + 8));
if x.is_finite() && y.is_finite() && z.is_finite() && x.abs() < 1e6 && y.abs() < 1e6 && z.abs() < 1e6
{
index.entry((q(x), q(y), q(z))).or_default().push(o as u32);
}
o += 4;
}
let mut deltas: HashMap<i64, Vec<(u32, u32)>> = HashMap::new();
for d in &draws {
let k = (q(d.pos[0][0]), q(d.pos[0][1]), q(d.pos[0][2]));
// ±1 in each axis: the log rounds, our file value may round the
// other way at a tie.
for dx in -1..=1i64 {
for dy in -1..=1i64 {
for dz in -1..=1i64 {
let Some(cands) = index.get(&(k.0 + dx, k.1 + dy, k.2 + dz)) else {
continue;
};
for &off in cands {
for stride in (12..=64).step_by(4) {
let ok = (1..4).all(|j| {
let at = off as usize + j * stride;
at + 12 <= bytes.len()
&& (0..3).all(|c| {
(be(at + c * 4) - d.pos[j][c]).abs() <= 1e-4
})
});
if ok {
deltas
.entry(d.vbase as i64 - off as i64)
.or_default()
.push((d.vbase, d.vcount));
break;
}
}
}
}
}
}
}
let mut top: Vec<_> = deltas.into_iter().collect();
top.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
if let Some((delta, hits)) = top.first() {
if hits.len() >= 3 {
placed += hits.len();
println!(
"{:<22} base=0x{:<10X} buffers={}",
f.file_name().unwrap().to_string_lossy(),
delta,
hits.len()
);
}
}
}
eprintln!("{placed} draws placed in a container");
}

View File

@@ -0,0 +1,81 @@
//! RE probe: what does the disc say about CHALLENGE / EXTRA missions?
//!
//! The title's stage-config reader (`0x82184b98`..`0x82184e94`) selects one of three
//! config sections by a mode field at `obj+144`:
//! mode == 3 -> "EXTRA"
//! mode == 5 or 6 -> "CHALLENGE"
//! otherwise -> "FILE"
//! so challenge missions are a *mode*, not a separate stage numbering. This probe
//! asks the disc which stage records exist and what GP_CHALLENGE.pak carries.
//!
//! Run: cargo run --release -p sylpheed-formats --example challenge_map -- <disc-root>
use sylpheed_formats::{idxd::IdxdObject, pak::PakArchive};
fn main() {
let disc = std::env::args().nth(1).unwrap_or_else(|| {
std::env::var("SYLPHEED_DISC").expect("pass disc root or set SYLPHEED_DISC")
});
println!("=== 1. StageResource records in GP_MAIN_GAME_E.pak ===");
let arc = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let mut rows = vec![];
for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
if o.schema_hash != 0x3c9ae32e {
continue;
}
let t = o.tokens();
let stage = t
.iter()
.find_map(|s| s.strip_prefix("EnumUnit_").map(|x| x.trim_end_matches(".tbl").to_string()))
.unwrap_or("?".into());
let bg = o.get_raw("BackGroundID").unwrap_or("?").to_string();
rows.push((stage, bg, t.len()));
}
rows.sort();
println!(" {} stage records", rows.len());
for (s, bg, n) in &rows {
println!(" {s:8} bg={bg:16} tokens={n}");
}
println!("\n=== 2. GP_CHALLENGE.pak contents ===");
match PakArchive::open(format!("{disc}/dat/GP_CHALLENGE.pak")) {
Ok(ch) => {
let mut by_schema: std::collections::BTreeMap<u32, usize> = Default::default();
let mut all_tokens: Vec<(u32, Vec<String>)> = vec![];
for e in ch.entries() {
let Ok(b) = ch.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
*by_schema.entry(o.schema_hash).or_default() += 1;
all_tokens.push((o.schema_hash, o.tokens().iter().map(|s| s.to_string()).collect()));
}
println!(" {} entries, {} IDXD objects", ch.entries().len(), all_tokens.len());
for (h, n) in &by_schema {
println!(" schema {h:08x} x{n}");
}
for (h, t) in all_tokens.iter().take(12) {
println!(" -- {h:08x}: {:?}", &t[..t.len().min(60)]);
}
}
Err(e) => println!(" open failed: {e}"),
}
println!("\n=== 3. tokens mentioning Challenge / EX across the main pak ===");
let mut hits: std::collections::BTreeSet<String> = Default::default();
for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
for t in o.tokens() {
let l = t.to_ascii_lowercase();
if l.contains("challenge") || t.ends_with("_EX") || t.contains("_EX4") || t.contains("_EX5") {
hits.insert(format!("{:08x} {t}", o.schema_hash));
}
}
}
for h in hits.iter().take(120) {
println!(" {h}");
}
println!(" ({} distinct)", hits.len());
}

View File

@@ -0,0 +1,78 @@
//! RE probe: the GamePart_ChallengeMission screen config.
//!
//! The class's code range (`0x82187E60`..`0x8218CF10`, bounded by the factory
//! creator thunks either side) references these config keys:
//! MISSIONS, MISSION_ID, NEW_STAGE, REQUIREMENT, REQUIREMENT_DESC, "Always",
//! GRAY_BUTTON, NORMAL_BUTTON, THUMBNAIL, STAGE_DESC, TEXT_STAGE,
//! RECORD_TYPE, "Time", TEXT_RECORD, BASE_INFO
//! i.e. the challenge list is a *config record* with a per-mission REQUIREMENT.
//! It lives in `tables.pak` (one copy per language), not in GP_CHALLENGE.pak.
//!
//! Run: cargo run --release -p sylpheed-formats --example challenge_screen -- <disc-root>
use sylpheed_formats::{idxd::IdxdObject, pak::PakArchive};
const KEYS: &[&str] = &[
"MISSIONS",
"MISSION_ID",
"REQUIREMENT",
"REQUIREMENT_DESC",
"NEW_STAGE",
"GRAY_BUTTON",
"NORMAL_BUTTON",
"RECORD_TYPE",
"THUMBNAIL",
"STAGE_DESC",
];
fn find(h: &[u8], n: &[u8]) -> bool {
h.windows(n.len()).any(|w| w == n)
}
fn main() {
let disc = std::env::args().nth(1).unwrap_or_else(|| {
std::env::var("SYLPHEED_DISC").expect("pass disc root or set SYLPHEED_DISC")
});
let dat = format!("{disc}/dat");
let mut paks: Vec<_> = std::fs::read_dir(&dat)
.expect("dat dir")
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
.collect();
paks.sort();
for p in &paks {
let Ok(arc) = PakArchive::open(p) else { continue };
for (i, e) in arc.entries().iter().enumerate() {
let Ok(b) = arc.read(e) else { continue };
if KEYS.iter().filter(|k| find(&b, k.as_bytes())).count() < KEYS.len() {
continue;
}
let name = p.file_name().unwrap().to_string_lossy().to_string();
match IdxdObject::parse(&b) {
Ok(o) => {
let toks = o.tokens();
// language tag: the PATH value, e.g. "dat\GP_CHALLENGE.pak+eng\"
let lang = toks
.iter()
.find(|t| t.contains("GP_CHALLENGE.pak+"))
.map(|t| t.to_string())
.unwrap_or_default();
println!(
"\n===== {name} entry #{i} schema {:08x} {} tokens [{lang}] =====",
o.schema_hash,
toks.len()
);
// Print the pool in order; the record is key/value interleaved and
// IDXD dedupes repeats, so pairing is read by eye, not asserted.
for (j, t) in toks.iter().enumerate() {
println!(" {j:3} {t}");
}
}
Err(e) => println!("\n===== {name} entry #{i}: IDXD parse failed: {e} ====="),
}
}
}
}

View File

@@ -0,0 +1,34 @@
//! How much of the "decoded geometry" belongs to SCENE COMPOSITES rather than to
//! real meshes? A composite (`rou_*`, `e_rou_*`) carries node transforms; the
//! anchor scan nevertheless finds a block for it, and that pseudo-geometry lands
//! in coverage and consistency counts.
//! Usage: composite_geometry <resource3d_dir>
use sylpheed_formats::mesh::{scene_world_nodes, Xbg7Model};
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let (mut total, mut composite, mut composite_named) = (0usize, 0usize, 0usize);
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
total += 1;
let has_nodes = !scene_world_nodes(&bytes, &m.name).is_empty();
let named = m.name.starts_with("rou_") || m.name.starts_with("e_rou_");
if has_nodes {
composite += 1;
}
if named {
composite_named += 1;
}
}
}
println!(
"{total} decoded; {composite} have scene nodes (a composite), {composite_named} are named rou_/e_rou_"
);
}

View File

@@ -0,0 +1,87 @@
//! Minority report: which container decodes a shared resource differently from
//! all the others?
//!
//! Cross-container consistency has been measured as "do the spans agree", which
//! only says a resource is inconsistent — not which copy is wrong. With three or
//! more copies the majority is the reference, and the minority names the
//! container AND the resource to look at. That is what caught the `n054`/`n056`
//! shift chain after the exact-coverage fix.
//!
//! Usage: consensus_check <resource3d_dir> [--list]
use sylpheed_formats::mesh::Xbg7Model;
use std::collections::{BTreeMap, HashMap};
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let list = std::env::args().any(|a| a == "--list");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
// name -> [(container, verts, tris, span)]
let mut seen: BTreeMap<String, Vec<(String, usize, usize, [i64; 3])>> = BTreeMap::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for s in &m.meshes {
for q in &s.positions {
for k in 0..3 {
lo[k] = lo[k].min(q[k]);
hi[k] = hi[k].max(q[k]);
}
}
}
if lo[0] == f32::MAX {
continue;
}
let v: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
let t: usize = m.meshes.iter().map(|s| s.indices.len() / 3).sum();
let span = [
(hi[0] - lo[0]).round() as i64,
(hi[1] - lo[1]).round() as i64,
(hi[2] - lo[2]).round() as i64,
];
seen.entry(m.name.clone()).or_default().push((where_.clone(), v, t, span));
}
}
let (mut resources, mut minority) = (0usize, 0usize);
let mut rows: Vec<String> = Vec::new();
for (name, list_) in &seen {
// Only compare decodes that agree on how much geometry they found, and
// only where a majority can exist.
if list_.len() < 3 || !list_.iter().all(|e| e.1 == list_[0].1 && e.2 == list_[0].2) {
continue;
}
let mut votes: HashMap<[i64; 3], usize> = HashMap::new();
for e in list_ {
*votes.entry(e.3).or_default() += 1;
}
let (best, n) = votes.iter().max_by_key(|(_, n)| **n).unwrap();
if *n * 2 <= list_.len() {
continue; // no majority — cannot call anyone the odd one out
}
resources += 1;
for e in list_.iter().filter(|e| e.3 != *best) {
minority += 1;
rows.push(format!(
"{:<22} {name:<26} v={:<6} {:?} vs the other {n} containers' {:?}",
e.0, e.1, e.3, best
));
}
}
if list {
for r in &rows {
println!("{r}");
}
}
println!(
"{minority} minority decodes across {resources} resources that have a majority"
);
}

View File

@@ -16,7 +16,7 @@
//! Usage: //! Usage:
//! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \ //! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit] //! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]
//! e.g. SYLPHEED_ISO="/home/fabi/RE Project Sylpheed/Project Sylpheed - Arc of //! e.g. SYLPHEED_ISO="/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of
//! Deception (USA, Europe) (En,Ja).iso" \ //! Deception (USA, Europe) (En,Ja).iso" \
//! cargo run --release --example correlate_capture -- \ //! cargo run --release --example correlate_capture -- \
//! xenia_ship_capture.log Stage_S01 e106 bdy_04 --emit //! xenia_ship_capture.log Stage_S01 e106 bdy_04 --emit

View File

@@ -0,0 +1,280 @@
//! Correlate a capture **per frame** and cross-check the frames against each
//! other — the placement is only believable if independent frames agree.
//!
//! `correlate_capture` treats one capture log as one set of draws. It is not:
//! an F10 press dumps ~14 frames with no delimiter, and `WV_ref⁻¹ · WV_p` only
//! cancels the camera within a single frame (see
//! [`sylpheed_formats::ship_capture::segment_frames`]). With a moving camera the
//! mixed-frame answer is wrong, and — worse — it is wrong *silently*.
//!
//! So: segment, correlate each frame independently, then report per part the
//! median translation and the spread across frames. A part whose spread is a
//! few units is measured; a part that swings by hundreds is not, whatever the
//! single-shot number said.
//!
//! Usage:
//! SYLPHEED_ISO=... cargo run --release --example correlate_frames -- \
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--min-parts N]
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship::{is_base_part, ship_id_of};
use sylpheed_formats::ship_capture::{
correlate, parse_capture, parse_drawlog, segment_frames, PartKey,
};
use sylpheed_formats::xiso::open_iso;
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
fn median(mut v: Vec<f32>) -> f32 {
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = v.len();
if n % 2 == 1 { v[n / 2] } else { 0.5 * (v[n / 2 - 1] + v[n / 2]) }
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect();
if positional.len() < 3 {
eprintln!("usage: correlate_frames <capture.log> <Stage_SNN> <ship_id> [ref_part] [--min-parts N]");
std::process::exit(2);
}
let (log, stage, id) = (positional[0], positional[1], positional[2]);
let ref_sub = positional.get(3).map(|s| s.as_str()).unwrap_or("bdy_01");
let min_parts: usize = args
.iter()
.position(|a| a == "--min-parts")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse().ok())
.unwrap_or(3);
let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
let text = std::fs::read_to_string(log).expect("read log");
let mut draws = parse_capture(&text);
if draws.is_empty() {
draws = parse_drawlog(&text);
}
let frames = segment_frames(&draws);
println!("{} draws → {} camera-consistent blocks", draws.len(), frames.len());
let bytes = {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
rt.block_on(async {
let mut r = open_iso(Path::new(&iso)).await.unwrap();
r.read_file(&format!("hidden/resource3d/{stage}.xpr")).await.unwrap()
})
};
let names = xbg7_resource_names(&bytes);
let base_parts: Vec<String> = names
.iter()
.filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str()))
.cloned()
.collect();
let mut want: HashSet<String> = base_parts.iter().cloned().collect();
for p in &base_parts {
for suf in ["_m", "_l", "_d"] {
let c = format!("{p}{suf}");
if names.contains(&c) {
want.insert(c);
}
}
}
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
let positions_of = |name: &str| -> Option<Vec<[f32; 3]>> {
let m = models.iter().find(|m| m.name == name)?;
Some(m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect())
};
// part -> [T per frame], and how many frames placed it at all.
let mut samples: BTreeMap<String, Vec<[f32; 3]>> = BTreeMap::new();
let mut rots: BTreeMap<String, Vec<[[f32; 3]; 3]>> = BTreeMap::new();
let mut used_frames = 0usize;
for (fi, fr) in frames.iter().enumerate() {
let mut keys: Vec<PartKey> = Vec::new();
for part in &base_parts {
let variants =
[part.clone(), format!("{part}_m"), format!("{part}_l"), format!("{part}_d")];
let union: Vec<[f32; 3]> =
variants.iter().filter_map(|v| positions_of(v)).flatten().collect();
for cand in &variants {
if let Some(pos) = positions_of(cand) {
let vcount = pos.len() as u32;
if fr.iter().any(|d| d.vcount == vcount) {
keys.push(PartKey { part: part.clone(), vcount, ref_pos: union.clone() });
}
}
}
}
let Some(ship) = correlate(id, fr, &keys, ref_sub) else { continue };
if ship.parts.len() < min_parts {
continue;
}
// Placements are expressed in the REFERENCE part's frame, so blocks that
// fell back to a different reference (because the requested one was not
// drawn in that block) are in a different coordinate system entirely.
// Averaging them together is what makes an otherwise clean result look
// like it disagrees by exactly the distance between the two references.
if !ship.reference.contains(ref_sub) {
println!(" block {fi:2}: skipped — reference fell back to {}", ship.reference);
continue;
}
used_frames += 1;
println!(
" block {fi:2} ({:4} draws): ref={} parts={}",
fr.len(),
ship.reference,
ship.parts.len()
);
for p in &ship.parts {
samples.entry(p.part.clone()).or_default().push(p.t);
rots.entry(p.part.clone()).or_default().push(p.m);
}
}
if used_frames == 0 {
println!("\nno block placed {min_parts}+ parts — the ship is not drawn close enough");
return;
}
// Aggregate by CONSENSUS, not by average. A stage holds several ships of the
// same class, they share vertex buffers, and a block can therefore contain
// one instance's full-LOD part next to another instance's `_m` copy — two
// different buffers, so nothing splits them, and the recovered translation
// then belongs to whichever instance the correlator happened to pick. Those
// are outliers by thousands of units, so a mean or a median over all blocks
// is meaningless; the largest cluster of blocks that agree with each other
// is the placement, and the rest are honestly reported as other instances.
const TOL: f32 = 25.0; // float noise in the WV products, measured ≤0.4
let cluster = |ts: &Vec<[f32; 3]>| -> (Vec<usize>, usize) {
let mut best: Vec<usize> = Vec::new();
for seed in ts {
let near: Vec<usize> = ts
.iter()
.enumerate()
.filter(|(_, t)| (0..3).all(|i| (t[i] - seed[i]).abs() < TOL))
.map(|(i, _)| i)
.collect();
if near.len() > best.len() {
best = near;
}
}
let out = ts.len() - best.len();
(best, out)
};
println!("\nacross {used_frames} blocks — consensus T (largest agreeing cluster):");
let mut agree = 0usize;
let mut consensus: BTreeMap<String, ([f32; 3], [[f32; 3]; 3])> = BTreeMap::new();
for (part, ts) in &samples {
let (cl_idx, outliers) = cluster(ts);
let cl: Vec<[f32; 3]> = cl_idx.iter().map(|&i| ts[i]).collect();
let med = [
median(cl.iter().map(|t| t[0]).collect()),
median(cl.iter().map(|t| t[1]).collect()),
median(cl.iter().map(|t| t[2]).collect()),
];
let spread: Vec<f32> = (0..3)
.map(|a| {
let v: Vec<f32> = cl.iter().map(|t| t[a]).collect();
v.iter().cloned().fold(f32::MIN, f32::max) - v.iter().cloned().fold(f32::MAX, f32::min)
})
.collect();
let verdict = if cl.len() < 2 {
"single block — unverified"
} else {
agree += 1;
"AGREES"
};
// How much the ROTATION varies between blocks that agree on position.
// A part bolted to the hull reads 0 here; a part that is articulating
// (turret aiming, engine gimballing) does not — which is what separates
// "the assembler has the rotation wrong" from "the part moved".
let all_ms = rots.get(part).cloned().unwrap_or_default();
let ms: Vec<[[f32; 3]; 3]> =
cl_idx.iter().filter_map(|&i| all_ms.get(i).copied()).collect();
// Keep a rotation from INSIDE the cluster: the first sample overall can
// belong to another instance, and diffing static against that reads as a
// rotation error that is really an instance mix-up.
consensus.insert(
part.clone(),
(med, ms.first().copied().unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])),
);
let rot_var = ms
.iter()
.flat_map(|a| ms.iter().map(move |b| (a, b)))
.map(|(a, b)| {
(0..3)
.flat_map(|i| (0..3).map(move |j| (i, j)))
.map(|(i, j)| (a[i][j] - b[i][j]).abs())
.fold(0.0f32, f32::max)
})
.fold(0.0f32, f32::max);
println!(
" {part:18} {:2}/{:2} blocks T=[{:9.1}{:9.1}{:9.1}] spread=[{:6.2}{:6.2}{:6.2}] rotVar={rot_var:5.3} {verdict}{}",
cl.len(), ts.len(), med[0], med[1], med[2], spread[0], spread[1], spread[2],
if outliers > 0 { format!(" (+{outliers} other-instance)") } else { String::new() }
);
}
println!("\n{agree}/{} parts reproduce across blocks", samples.len());
// `--static <Stage_SNN.xpr>`: diff the offline assembler against this
// ground truth. Static placements are in ship space, so both sides are
// re-expressed in the reference part's frame before comparing — and the
// rotation is compared too, because "wrong orientation" is half of the
// reported viewer symptom and a translation-only check cannot see it.
let Some(si) = args.iter().position(|a| a == "--static") else { return };
let Some(spath) = args.get(si + 1) else { return };
let sbytes = std::fs::read(spath).expect("read stage container");
// `include_external = true` — the engine cluster, the bridge and cross-id
// turrets live in SEPARATE composites (`e_rou_e106_eng`, 3 nodes) that the
// primary-composite pass does not reach. With `false` an e106 assembles as
// 5 parts and the runtime capture's bridge/nacelles read as "not produced by
// assemble_ship", which is a property of the caller, not of the format.
let scene = sylpheed_formats::ship::assemble_ship(&sbytes, id, true);
let Some(sref) = scene.iter().find(|p| p.resource.contains(ref_sub)) else {
println!("\nstatic: no part matching '{ref_sub}' — cannot align frames");
return;
};
// The reference is placed axis-aligned in every ship seen so far; if that
// ever stops holding, the rotation would have to be unwound here too.
println!("\nstatic vs runtime (both relative to {}):", sref.resource);
let mut worst_t = 0.0f32;
let mut worst_r = 0.0f32;
for (part, (med, rm)) in &consensus {
let (med, rm) = (*med, *rm);
// A part may be instanced (mirrored twins share a resource name); take
// the static copy that lands nearest the captured one.
let cands: Vec<&sylpheed_formats::mesh::ScenePart> =
scene.iter().filter(|p| &p.resource == part).collect();
if cands.is_empty() {
println!(" {part:18} — not produced by assemble_ship");
continue;
}
let rel = |p: &sylpheed_formats::mesh::ScenePart| {
[p.t[0] - sref.t[0], p.t[1] - sref.t[1], p.t[2] - sref.t[2]]
};
let best = cands
.iter()
.min_by(|a, b| {
let d = |p: &sylpheed_formats::mesh::ScenePart| {
let r = rel(p);
(0..3).map(|i| (r[i] - med[i]).powi(2)).sum::<f32>()
};
d(a).partial_cmp(&d(b)).unwrap()
})
.unwrap();
let r = rel(best);
let dt: Vec<f32> = (0..3).map(|i| r[i] - med[i]).collect();
let dtm = dt.iter().map(|v| v.abs()).fold(0.0f32, f32::max);
let drm = (0..3)
.flat_map(|i| (0..3).map(move |j| (i, j)))
.map(|(i, j)| (best.m[i][j] - rm[i][j]).abs())
.fold(0.0f32, f32::max);
worst_t = worst_t.max(dtm);
worst_r = worst_r.max(drm);
let mark = if dtm < 1.0 && drm < 0.02 { "MATCH" } else { "DIFFERS" };
println!(
" {part:18} static=[{:9.1}{:9.1}{:9.1}] dT={dtm:7.2} dR={drm:6.3} {mark}",
r[0], r[1], r[2]
);
}
println!("\nworst dT={worst_t:.2} worst dR={worst_r:.3} ({} static parts, {} captured)",
scene.len(), samples.len());
}

View File

@@ -0,0 +1,48 @@
//! Do real index buffers address their whole vertex pool?
//!
//! `validate_block` rejects a block whose indices reach fewer than `vtx_count4`
//! vertices ("buffer not covered"). That gate is the furthest-reached rejection
//! for a handful of resources that never decode — so the question is whether it
//! is well founded. This measures the slack on every block that DOES decode: if
//! real geometry always covers its pool, under-coverage is good evidence of a
//! wrong candidate and the gate stands.
use sylpheed_formats::mesh::Xbg7Model;
use std::collections::BTreeMap;
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let mut hist: BTreeMap<i64, usize> = BTreeMap::new();
let mut worst: Vec<(i64, String)> = Vec::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
for sub in &m.meshes {
if sub.positions.is_empty() || sub.indices.is_empty() {
continue;
}
let max_idx = *sub.indices.iter().max().unwrap() as i64;
let slack = sub.positions.len() as i64 - 1 - max_idx;
*hist.entry(slack.min(20)).or_default() += 1;
if slack > 4 {
worst.push((slack, format!("{} in {}", m.name, f.file_name().unwrap().to_string_lossy())));
}
}
}
}
println!("unreferenced tail vertices (vtx_count 1 max index), over decoded sub-meshes:");
for (slack, n) in &hist {
println!(" {:>3}{} : {n}", slack, if *slack == 20 { "+" } else { " " });
}
worst.sort_by_key(|(s, _)| std::cmp::Reverse(*s));
for (s, w) in worst.iter().take(5) {
println!(" largest slack {s}: {w}");
}
}

View File

@@ -0,0 +1,71 @@
//! Dump an XBG7 resource's descriptor around each index marker, to see whether a
//! grouped pool carries a declaration PER sub-mesh (the 2026-08-13 mixed-stride
//! finding says it must: n201's four sub-meshes use strides 24/24/24/28 and four
//! different vertex shaders).
//! Usage: desc_dump <container.xpr> <resource>
fn be32(b: &[u8], o: usize) -> u32 {
u32::from_be_bytes(b[o..o + 4].try_into().unwrap())
}
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
let want = &a[2];
// Walk the XPR2 directory by hand (the crate's reader is private).
// 16-byte directory entries at 0x10: type tag, data offset, descriptor size,
// name offset — all relative to 0x10 (see texture::Xpr2ResourceEntry).
let num = be32(&bytes, 12) as usize;
for i in 0..num {
let e = &bytes[0x10 + i * 16..0x10 + i * 16 + 16];
let tag = &e[0..4];
let data_off = be32(e, 4) as usize + 0x10;
let desc_size = be32(e, 8) as usize;
let name_off = be32(e, 12) as usize + 0x10;
if tag != b"XBG7" {
continue;
}
let name = {
let s = name_off.min(bytes.len());
let end = bytes[s..].iter().position(|&c| c == 0).unwrap_or(0) + s;
String::from_utf8_lossy(&bytes[s..end]).to_string()
};
if name != *want {
continue;
}
let desc = &bytes[data_off..(data_off + desc_size).min(bytes.len())];
println!("{name}: descriptor at 0x{data_off:X}, {} bytes", desc.len());
// markers: a == c*2, c%3==0, vertex count 32 bytes earlier
let mut rel = 0usize;
while rel + 8 <= desc.len() {
let (x, c) = (be32(desc, rel), be32(desc, rel + 4));
if c >= 3 && c % 3 == 0 && c < 400_000 && x == c * 2 && rel >= 32 {
let vc = be32(desc, rel - 32);
if (3..=200_000).contains(&vc) {
println!("\n marker @0x{rel:X}: {vc} verts / {c} indices — declaration triples after it:");
let mut r = rel + 8;
let mut stride = 0u32;
for _ in 0..12 {
if r + 12 > desc.len() {
break;
}
let (o, code, usage) = (be32(desc, r), be32(desc, r + 4), be32(desc, r + 8) >> 16);
if o == 0x00FF_0000 || code == 0xFFFF_FFFF || o > 0x1000 {
println!(" end marker @0x{r:X}: off=0x{o:X} code=0x{code:X}");
break;
}
println!(" off {o:>3} code 0x{:06X} usage {usage}", code & 0xFF_FFFF);
stride = stride.max(o + 4);
r += 12;
}
println!(" (min stride from element offsets: {stride})");
rel += 8;
continue;
}
}
rel += 4;
}
return;
}
eprintln!("resource not found");
}

View File

@@ -0,0 +1,80 @@
//! Calibrate the connectivity cap against the whole disc.
//!
//! `XBG7_EDGE_CAP` sets the cap; this reports, for one setting, how much
//! geometry decodes and how self-consistent it is across containers — the two
//! numbers any change to the cap has to trade off. Run it once per cap value.
use sylpheed_formats::mesh::Xbg7Model;
use std::collections::BTreeMap;
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
// name -> (verts, tris) -> spans seen, exactly as mesh_consistency_disc.rs.
let mut seen: BTreeMap<String, Vec<([i64; 3], usize, usize)>> = BTreeMap::new();
let (mut models, mut verts) = (0usize, 0usize);
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for s in &m.meshes {
for q in &s.positions {
for k in 0..3 {
lo[k] = lo[k].min(q[k]);
hi[k] = hi[k].max(q[k]);
}
}
}
if lo[0] == f32::MAX {
continue;
}
let v: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
let t: usize = m.meshes.iter().map(|s| s.indices.len() / 3).sum();
models += 1;
verts += v;
if std::env::var("DUMP").is_ok() {
// Per-resource signature, so two cap settings can be diffed:
// a cap change that silently MOVES an existing anchor is the
// risk a coverage count cannot see.
println!(
"{}|{}|{v}|{t}|{}|{}|{}|{}",
f.file_name().unwrap().to_string_lossy(),
m.name,
m.meshes[0].vbuf_offset.unwrap_or(0),
(hi[0] - lo[0]).round() as i64,
(hi[1] - lo[1]).round() as i64,
(hi[2] - lo[2]).round() as i64
);
}
seen.entry(m.name.clone()).or_default().push((
[
(hi[0] - lo[0]).round() as i64,
(hi[1] - lo[1]).round() as i64,
(hi[2] - lo[2]).round() as i64,
],
v,
t,
));
}
}
let (mut shared, mut inconsistent) = (0usize, 0usize);
for (_, list) in &seen {
if list.len() < 2 || !list.iter().all(|e| e.1 == list[0].1 && e.2 == list[0].2) {
continue;
}
shared += 1;
if list.iter().any(|e| e.0 != list[0].0) {
inconsistent += 1;
}
}
println!(
"cap={} models={models} verts={verts} shared={shared} inconsistent={inconsistent}",
std::env::var("XBG7_EDGE_CAP").unwrap_or_else(|_| "library default".into())
);
}

View File

@@ -0,0 +1,106 @@
//! Does every part of a ship sit inside the envelope its siblings describe?
//!
//! `slab_screen` compared *scale* and could not see the `e106` slab. What the eye
//! used when the render exposed it was **relationship**: a blocky mass sitting
//! apart from the hull. This measures that — assemble a ship, and for each part
//! ask how far its world box protrudes beyond the box of all the OTHER parts,
//! relative to the ship's own size. A mis-anchored block sticks out; a real part,
//! however big, is part of the silhouette.
//!
//! Usage: envelope_screen <resource3d_dir> [protrusion_fraction]
use sylpheed_formats::mesh::Xbg7Model;
use sylpheed_formats::ship::{assemble_ship, ship_id_of};
use std::collections::{BTreeSet, HashSet};
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let limit: f32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(0.35);
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let mut flagged = 0usize;
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let ids: BTreeSet<String> = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false)
.iter()
.filter_map(|m| ship_id_of(&m.name).map(|s| s.to_string()))
.collect();
for id in &ids {
let placed = assemble_ship(&bytes, id, true);
if placed.len() < 3 {
continue;
}
let want: HashSet<String> = placed.iter().map(|p| p.resource.clone()).collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
// World box per placement.
let mut boxes: Vec<(String, [f32; 3], [f32; 3])> = Vec::new();
for p in &placed {
let Some(m) = models.iter().find(|m| m.name == p.resource) else { continue };
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for s in &m.meshes {
for q in &s.positions {
let w = p.apply(*q);
for k in 0..3 {
lo[k] = lo[k].min(w[k]);
hi[k] = hi[k].max(w[k]);
}
}
}
if lo[0] != f32::MAX {
boxes.push((p.resource.clone(), lo, hi));
}
}
if boxes.len() < 3 {
continue;
}
for i in 0..boxes.len() {
// Envelope of every OTHER part.
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for (j, b) in boxes.iter().enumerate() {
if i == j {
continue;
}
for k in 0..3 {
lo[k] = lo[k].min(b.1[k]);
hi[k] = hi[k].max(b.2[k]);
}
}
// Per-AXIS: a bow legitimately extends the ship along its long
// axis, so protrusion only means something measured against the
// envelope's size IN THAT AXIS. The e106 slab stuck ~1 500 out in
// Y where its siblings spanned ~800.
let mut worst = 0.0f32;
let mut worst_out = 0.0f32;
for k in 0..3 {
let size_k = hi[k] - lo[k];
if size_k <= 1.0 {
continue;
}
let out_k = (lo[k] - boxes[i].1[k]).max(boxes[i].2[k] - hi[k]).max(0.0);
if out_k / size_k > worst {
worst = out_k / size_k;
worst_out = out_k;
}
}
let (out, size) = (worst_out, 1.0f32);
let _ = size;
if worst > limit {
flagged += 1;
println!(
"{:<20} {:<22} protrudes {:>7.0} beyond its siblings ({:.0}% of the ship)",
f.file_name().unwrap().to_string_lossy(),
boxes[i].0,
out,
100.0 * worst
);
}
}
}
}
println!("{flagged} parts protrude more than {:.0}% of their ship's size", 100.0 * limit);
}

View File

@@ -0,0 +1,35 @@
//! Does a FILTERED decode agree with the full one?
//!
//! Distinct anchor assignment resolves collisions against the set of resources
//! being decoded — so `models_named` (a filtered subset, used by the ship
//! assembler and the viewer) can reach a different answer from a whole-container
//! decode. This measures that directly.
//! Usage: filter_consistency <container.xpr> [resource...]
use sylpheed_formats::mesh::Xbg7Model;
use std::collections::HashSet;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let full = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false);
let names: Vec<String> = if a.len() > 2 {
a[2..].to_vec()
} else {
full.iter().map(|m| m.name.clone()).collect()
};
let mut differ = 0usize;
for n in &names {
let want: HashSet<String> = std::iter::once(n.clone()).collect();
let one = Xbg7Model::models_named(&bytes, &want, &|| false);
let (Some(f), Some(s)) = (full.iter().find(|m| &m.name == n), one.first()) else {
continue;
};
let off = |m: &Xbg7Model| m.meshes.first().and_then(|s| s.vbuf_offset).unwrap_or(0);
if off(f) != off(s) {
differ += 1;
if differ <= 10 {
println!("{n}: full decode at 0x{:x}, filtered at 0x{:x}", off(f), off(s));
}
}
}
println!("{differ} of {} resources decode differently when filtered", names.len());
}

View File

@@ -0,0 +1,27 @@
//! Where could a resource's index buffer be, given the vertex buffer a runtime
//! capture proves the engine drew from? Tests the anchor scan's adjacency
//! assumption against ground truth.
use sylpheed_formats::mesh::{debug_find_index_buffer, debug_resource_params};
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
for pair in a[2..].iter() {
let (name, off) = pair.split_once('@').unwrap();
let vb = usize::from_str_radix(off.trim_start_matches("0x"), 16).unwrap();
let params = debug_resource_params(&bytes, name);
let markers = params.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
println!("{name} @ 0x{vb:x} markers={markers:?}");
let hits = debug_find_index_buffer(&bytes, name, vb);
if hits.is_empty() {
println!(" no index buffer anywhere in the container validates this block");
}
let mut hits = hits;
hits.sort_by_key(|(_, d)| d.abs());
for (ib, d) in hits.iter().take(8) {
println!(" ib 0x{ib:x} vb-ib = {d} bytes");
}
if hits.len() > 8 {
println!("{} total", hits.len());
}
}
}

View File

@@ -0,0 +1,44 @@
//! Does the container hold an X-mirrored copy of a resource's decoded buffer?
//!
//! The twin invariant (see `twin_mirror_audit`) flags `…_01`/`…_02` pairs that
//! decode to unrelated geometry. If the mirror of one twin's buffer exists
//! somewhere else in the container, that offset is where the other twin belongs
//! and the pair is a mis-anchor; if it does not exist, the pair is simply not a
//! mirrored pair.
//!
//! Usage: find_mirror <container.xpr> <resource>...
use sylpheed_formats::mesh::Xbg7Model;
use std::collections::HashSet;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let want: HashSet<String> = a[2..].iter().cloned().collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
for m in &models {
let pos: Vec<[f32; 3]> = m.meshes.iter().flat_map(|s| s.positions.clone()).take(8).collect();
if pos.len() < 8 {
continue;
}
let anchored = m.meshes[0].vbuf_offset.unwrap_or(0);
let (mut direct, mut mirror) = (Vec::new(), Vec::new());
for o in (0..bytes.len().saturating_sub(12 + 8 * 24)).step_by(4) {
for (flip, out) in [(1.0f32, &mut direct), (-1.0f32, &mut mirror)] {
if (0..8).all(|k| {
let at = o + k * 24;
(be(at) - flip * pos[k][0]).abs() <= 1e-4
&& (be(at + 4) - pos[k][1]).abs() <= 1e-4
&& (be(at + 8) - pos[k][2]).abs() <= 1e-4
}) {
out.push(o);
}
}
}
println!(
"{:<20} anchored 0x{anchored:x} direct copies {:x?} mirrored copies {:x?}",
m.name, direct, mirror
);
}
}

View File

@@ -0,0 +1,60 @@
//! Which gate stops the resources that never decode?
//! Usage: gate_histogram <resource3d_dir> [max_resources]
use sylpheed_formats::mesh::{debug_best_rejection, xbg7_resource_names, Xbg7Model};
use std::collections::{BTreeMap, HashSet};
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let cap: usize = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(usize::MAX);
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let mut hist: BTreeMap<usize, (usize, String)> = BTreeMap::new();
let mut done = 0usize;
for f in &files {
if done >= cap {
break;
}
let Ok(bytes) = std::fs::read(f) else { continue };
let names = xbg7_resource_names(&bytes);
if names.is_empty() {
continue;
}
let got: HashSet<String> = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false)
.into_iter()
.map(|m| m.name)
.collect();
for n in names.iter().filter(|n| !got.contains(*n)) {
if done >= cap {
break;
}
done += 1;
if let Some((rank, why)) = debug_best_rejection(&bytes, n) {
let e = hist.entry(rank).or_insert((0, String::new()));
e.0 += 1;
if e.1.is_empty() {
e.1 = format!("{n}: {why}");
}
}
}
}
println!("furthest gate reached, over {done} resources that never decode:");
let label = |r: usize| match r {
0 => "no gate reached",
1 => "index out of range",
2 => "buffer not covered by indices",
3 => "degenerate / implausible positions",
4 => "connectivity (mean edge / diagonal)",
5 => "winding consistency",
9 => "grouped pool (different path)",
_ => "?",
};
for (r, (n, ex)) in &hist {
println!(" {:<38} {n:>5} e.g. {ex}", label(*r));
}
}

View File

@@ -0,0 +1,2 @@
fn main(){let a:Vec<String>=std::env::args().collect();let b=std::fs::read(&a[1]).unwrap();
for l in sylpheed_formats::mesh::debug_grouped_report(&b,&a[2],a[3].parse().unwrap()){println!("{l}")}}

View File

@@ -0,0 +1,32 @@
//! Dump a fingerprint of every decoded index run, so two decoder settings can be
//! diffed exactly. Usage: index_hash_dump <resource3d_dir>
use sylpheed_formats::mesh::Xbg7Model;
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let w = f.file_name().unwrap().to_string_lossy().to_string();
for m in Xbg7Model::stage_models(&bytes) {
for (k, sm) in m.meshes.iter().enumerate() {
let mut h = 1469598103934665603u64;
for i in &sm.indices {
h = (h ^ *i as u64).wrapping_mul(1099511628211);
}
println!(
"{w} {} {k} vb={:?} n={} h={h:016x}",
m.name,
sm.vbuf_offset,
sm.indices.len()
);
}
}
}
}

View File

@@ -0,0 +1,134 @@
//! Is our index list one element late? A capture-free check of the `pad` choice.
//!
//! `anchor_pool_mesh` tries `ib = vb idx_count*2 pad` for `pad` in `0..=3`,
//! **pad 0 first and with the looser winding gate** (`XBG7_PAD0_CONSISTENCY`,
//! 0.70) than the pad ≥ 1 path (0.85). The 2026-08-13 index-byte comparison
//! against the runtime showed 17 draw batches whose captured indices equal our
//! decoded list shifted by exactly one element — all of them on buffers whose
//! real index data sits at pad 2. A one-element shift re-wires every triangle,
//! and it is invisible to every count-based metric (the count, the coverage and
//! the positions are all still right).
//!
//! This reproduces that finding from the container alone: for each resource it
//! reads the index run at pad 0 and at pad 2 and scores both on two properties a
//! correct triangle list has — **no degenerate triangles** (two equal indices)
//! and consistent winding against the stored normals.
//!
//! Usage: index_pad_check <container.xpr> [name-substring]
use sylpheed_formats::mesh::Xbg7Model;
fn be16(b: &[u8], at: usize) -> u32 {
((b[at] as u32) << 8) | b[at + 1] as u32
}
/// Degenerate triangles (a repeated index) and the fraction of triangles whose
/// face normal agrees with their vertices' stored normals.
fn score(idx: &[u32], pos: &[[f32; 3]], nrm: &[[f32; 3]]) -> (usize, f32, usize) {
let mut degen = 0usize;
let mut agree = 0usize;
let mut counted = 0usize;
for t in idx.chunks_exact(3) {
let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
if a == b || b == c || a == c {
degen += 1;
continue;
}
if a >= pos.len() || b >= pos.len() || c >= pos.len() {
continue;
}
let e1 = [pos[b][0] - pos[a][0], pos[b][1] - pos[a][1], pos[b][2] - pos[a][2]];
let e2 = [pos[c][0] - pos[a][0], pos[c][1] - pos[a][1], pos[c][2] - pos[a][2]];
let f = [
e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2],
e1[0] * e2[1] - e1[1] * e2[0],
];
if nrm.len() <= a {
continue;
}
let n = nrm[a];
let dot = f[0] * n[0] + f[1] * n[1] + f[2] * n[2];
counted += 1;
if dot > 0.0 {
agree += 1;
}
}
let frac = if counted == 0 { 0.0 } else { agree as f32 / counted as f32 };
(degen, frac.max(1.0 - frac), counted)
}
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let filter = a.get(2).cloned().unwrap_or_default();
let models = Xbg7Model::stage_models(&bytes);
println!(
"{:<22} {:>6} {:>6} {:<26} {:<26} verdict",
"resource", "verts", "idx", "pad 0 (what we decode)", "pad 2 (2 bytes earlier)"
);
let (mut better_p2, mut better_p0, mut tie) = (0usize, 0usize, 0usize);
let mut hist_ok: Vec<(usize, String, usize)> = Vec::new();
for m in &models {
if !filter.is_empty() && !m.name.contains(&filter) {
continue;
}
if m.meshes.len() != 1 {
continue; // single-block path only; grouped pools use another formula
}
let sm = &m.meshes[0];
let Some(vb) = sm.vbuf_offset else { continue };
let n = sm.indices.len();
if n < 6 || sm.normals.is_empty() || vb < n * 2 + 2 {
continue;
}
let read_at = |start: usize| -> Vec<u32> { (0..n).map(|k| be16(&bytes, start + k * 2)).collect() };
let l0 = read_at(vb - n * 2);
let l2 = read_at(vb - n * 2 - 2);
// Sanity: l0 must be what the decoder emitted, or the assumption that we
// took pad 0 is wrong for this resource and the comparison is meaningless.
let took_pad0 = l0 == sm.indices;
if !took_pad0 {
continue;
}
let (d0, c0, _) = score(&l0, &sm.positions, &sm.normals);
let (d2, c2, _) = score(&l2, &sm.positions, &sm.normals);
let max_i0 = l0.iter().max().copied().unwrap_or(0);
let max_i2 = l2.iter().max().copied().unwrap_or(0);
let v = sm.positions.len() as u32;
// A shifted list typically also overruns the pool by one vertex or leaves
// the last vertex unreferenced, so carry the coverage as evidence too.
let verdict = if d2 < d0 && c2 >= c0 {
better_p2 += 1;
"pad 2 is the real one"
} else if d0 < d2 || c0 > c2 {
better_p0 += 1;
"pad 0 fine"
} else {
tie += 1;
"indistinguishable"
};
if verdict == "pad 0 fine" {
hist_ok.push((d0, m.name.clone(), n));
}
if verdict != "pad 0 fine" || !filter.is_empty() {
println!(
"{:<22} {:>6} {:>6} degen {:>5} wind {:.3} max {:<5} degen {:>5} wind {:.3} max {:<5} {}",
m.name, v, n, d0, c0, max_i0, d2, c2, max_i2, verdict
);
}
}
println!("\npad 2 wins: {better_p2} · pad 0 wins: {better_p0} · indistinguishable: {tie}");
// Is "no degenerate triangle" a safe invariant for a correctly anchored
// block? Distribution over the resources where pad 0 is the better choice.
let zero = hist_ok.iter().filter(|(d, _, _)| *d == 0).count();
println!(
"of the {} pad-0-correct resources, {zero} have ZERO degenerate triangles",
hist_ok.len()
);
let mut worst: Vec<_> = hist_ok.iter().filter(|(d, _, _)| *d > 0).collect();
worst.sort_by_key(|(d, _, _)| std::cmp::Reverse(*d));
for (d, n, idx) in worst.iter().take(10) {
println!(" {n}: {d} degenerate of {} triangles", idx / 3);
}
}

View File

@@ -0,0 +1,156 @@
//! Invert the capture↔part match: instead of asking, per ship part, "is there a
//! draw with this vertex count?", ask of the **capture's** biggest draws "which
//! decoded resource in this stage container has that vertex count?".
//!
//! This is the diagnostic for the 2026-07-31 negative result (Stage_S02 capture,
//! zero parts correlated). It separates three hypotheses:
//! 1. LOD/variant vcount not covered by the correlator's variant list
//! → the big draws DO map to named resources, just not to the `_m`/`_l`/`_d`
//! set the correlator tries;
//! 2. position validation over-rejects
//! → the vcounts match the very parts we asked for (so the vcount key was
//! fine and the rejection happened later);
//! 3. a different draw path (instanced/batched/merged buffers)
//! → the big draws match NO resource in the container at all.
//!
//! Usage:
//! SYLPHEED_ISO=... cargo run --release --example invert_capture -- \
//! <capture.log> <Stage_SNN> [top_n] [--all]
//! `--all` lists every capture vcount, not just the `top_n` (default 40) largest.
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
use sylpheed_formats::xiso::open_iso;
use std::collections::{HashMap, HashSet};
use std::path::Path;
fn main() {
let args: Vec<String> = std::env::args().collect();
let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect();
let all = args.iter().any(|a| a == "--all");
if positional.len() < 2 {
eprintln!("usage: invert_capture <capture.log> <Stage_SNN> [top_n] [--all]");
std::process::exit(2);
}
let (log, stage) = (positional[0], positional[1]);
let top_n: usize = positional.get(2).and_then(|s| s.parse().ok()).unwrap_or(40);
let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
let text = std::fs::read_to_string(log).expect("read log");
let mut draws = parse_capture(&text);
if draws.is_empty() {
draws = parse_drawlog(&text);
println!("parsed {} draws (draw-logger format)", draws.len());
} else {
println!("parsed {} draws (F10 capture format)", draws.len());
}
// Decode EVERY geometry resource in the stage container, not just one ship's.
let bytes = {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
rt.block_on(async {
let mut r = open_iso(Path::new(&iso)).await.unwrap();
r.read_file(&format!("hidden/resource3d/{stage}.xpr")).await.unwrap()
})
};
let names = xbg7_resource_names(&bytes);
println!("{stage}.xpr: {} XBG7 resources", names.len());
let want: HashSet<String> = names.iter().cloned().collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
println!("decoded {} models", models.len());
// vcount -> resource names with that many vertices.
let mut by_vcount: HashMap<u32, Vec<String>> = HashMap::new();
for m in &models {
let v: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
by_vcount.entry(v as u32).or_default().push(m.name.clone());
}
// Per-submesh counts too: a draw may be one sub-mesh of a multi-mesh resource.
let mut by_sub_vcount: HashMap<u32, Vec<String>> = HashMap::new();
for m in &models {
for (i, s) in m.meshes.iter().enumerate() {
if m.meshes.len() > 1 {
by_sub_vcount
.entry(s.positions.len() as u32)
.or_default()
.push(format!("{}#{i}", m.name));
}
}
}
// Capture vcounts, de-duped by (vbase, vcount) so a re-drawn part counts once
// per distinct buffer.
let mut draw_count: HashMap<u32, usize> = HashMap::new();
let mut bufs: HashMap<u32, HashSet<u32>> = HashMap::new();
for d in &draws {
*draw_count.entry(d.vcount).or_default() += 1;
bufs.entry(d.vcount).or_default().insert(d.vbase);
}
let mut vcounts: Vec<u32> = draw_count.keys().copied().collect();
vcounts.sort_unstable_by(|a, b| b.cmp(a));
let matched_draws: usize = draws
.iter()
.filter(|d| by_vcount.contains_key(&d.vcount) || by_sub_vcount.contains_key(&d.vcount))
.count();
println!(
"\n{} distinct vcounts; {}/{} draws have a vcount present in {stage}.xpr ({:.1}%)",
vcounts.len(),
matched_draws,
draws.len(),
100.0 * matched_draws as f64 / draws.len().max(1) as f64
);
let shown = if all { vcounts.len() } else { top_n.min(vcounts.len()) };
println!("\nlargest capture vcounts (draws / distinct vbufs) → matching resources:");
for &v in vcounts.iter().take(shown) {
let n = draw_count[&v];
let b = bufs[&v].len();
let mut hit: Vec<String> = by_vcount.get(&v).cloned().unwrap_or_default();
let sub: Vec<String> = by_sub_vcount.get(&v).cloned().unwrap_or_default();
hit.extend(sub.into_iter().map(|s| format!("{s} (sub)")));
let label = if hit.is_empty() {
"— no resource".to_string()
} else {
let mut h = hit.clone();
h.sort();
h.truncate(6);
format!("{}{}", h.join(", "), if hit.len() > 6 { ", …" } else { "" })
};
println!(" vcount {v:6} draws {n:4} bufs {b:3} {label}");
}
// `--ship <id>`: every resource of one ship family, with its vertex count and
// whether the capture drew it — this is what shows an all-`_l` (far-LOD) frame.
if let Some(i) = args.iter().position(|a| a == "--ship") {
if let Some(id) = args.get(i + 1) {
let mut rows: Vec<(String, u32, usize)> = models
.iter()
.filter(|m| m.name.contains(id.as_str()))
.map(|m| {
let v = m.meshes.iter().map(|s| s.positions.len()).sum::<usize>() as u32;
(m.name.clone(), v, draw_count.get(&v).copied().unwrap_or(0))
})
.collect();
rows.sort_by(|a, b| a.0.cmp(&b.0));
let drawn = rows.iter().filter(|r| r.2 > 0).count();
println!("\n{id} resources in {stage}.xpr ({drawn}/{} with a drawn vcount):", rows.len());
for (name, v, n) in rows {
println!(" {name:28} vcount {v:6} {}", if n > 0 { format!("DRAWN ×{n}") } else { "".into() });
}
}
}
// The other direction, for orientation: the container's biggest resources and
// whether the capture ever drew that many vertices.
let mut sizes: Vec<(u32, String)> = models
.iter()
.map(|m| (m.meshes.iter().map(|s| s.positions.len()).sum::<usize>() as u32, m.name.clone()))
.collect();
sizes.sort_unstable_by(|a, b| b.0.cmp(&a.0));
println!("\nlargest resources in {stage}.xpr → drawn in the capture?");
for (v, name) in sizes.iter().take(top_n.min(sizes.len())) {
let n = draw_count.get(v).copied().unwrap_or(0);
println!(" {name:28} vcount {v:6} {}", if n > 0 { format!("DRAWN ×{n}") } else { "not drawn".into() });
}
}

View File

@@ -0,0 +1,90 @@
//! Where does each declared field sit inside the engine's live definition object?
//!
//! A live object carries no names, so the mapping has to be inferred: take a unit
//! whose disc record SETS a field, look for that value in the dumped words of the
//! matching live object, and keep the offsets that agree across several units.
//!
//! Run: live_offsets <disc-root> <live-dump.txt> <ID=0xVA> ...
//! e.g. … UN_f106_TCAF_Destroyer=0xbd3ee300 UN_f105_TCAF_Cruiser=0xbd40e200
use std::collections::BTreeMap;
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
fn main() {
let mut a = std::env::args().skip(1);
let disc = a.next().expect("disc root");
let dump = a.next().expect("live dump");
let pairs: Vec<(String, String)> = a
.filter_map(|s| s.split_once('=').map(|(i, v)| (i.to_string(), v.to_string())))
.collect();
// live: va -> offset -> f32
let mut live: BTreeMap<String, BTreeMap<usize, f32>> = BTreeMap::new();
let mut cur = String::new();
for line in std::fs::read_to_string(&dump).expect("dump").lines() {
if let Some(rest) = line.strip_prefix("=== ") {
cur = rest.trim().to_string();
continue;
}
let f: Vec<&str> = line.split_whitespace().collect();
if f.len() >= 4 && f[1].starts_with('+') {
if let (Ok(off), Ok(val)) =
(usize::from_str_radix(&f[1][1..], 16), f[3].parse::<f32>())
{
live.entry(cur.clone()).or_default().insert(off, val);
}
}
}
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).expect("pak");
// key -> offset -> how many units agree
let mut votes: BTreeMap<String, BTreeMap<usize, usize>> = BTreeMap::new();
let mut units = 0usize;
for entry in pak.entries() {
let Ok(bytes) = pak.read(entry) else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
let Some(id) = obj.get_raw("ID") else { continue };
let Some((_, va)) = pairs.iter().find(|(i, _)| i == id) else { continue };
let Some(words) = live.get(va) else { continue };
units += 1;
let mut numeric = 0usize;
let mut hit = 0usize;
for key in obj.tokens() {
let Some(v) = obj.get_f32(key) else { continue };
if !v.is_finite() || v == 0.0 {
continue; // zero matches everywhere and says nothing
}
numeric += 1;
let mut found = false;
for (off, w) in words {
if (*w - v).abs() <= v.abs() * 1e-6 {
*votes.entry(key.clone()).or_default().entry(*off).or_default() += 1;
found = true;
}
}
if found {
hit += 1;
}
}
eprintln!(" {id}: {numeric} numeric fields set on disc, {hit} found in the dumped window");
}
println!("{units} units correlated against their live objects\n");
println!("{:<28} {:>8} offsets agreeing (votes)", "field", "unique?");
for (key, offs) in &votes {
let best = offs.iter().max_by_key(|(_, n)| **n).unwrap();
if *best.1 < units.max(2) {
continue; // needs every correlated unit to agree
}
let list: Vec<String> = offs
.iter()
.filter(|(_, n)| **n == *best.1)
.map(|(o, n)| format!("+{o:03x}×{n}"))
.collect();
println!(
"{:<28} {:>8} {}",
key,
if list.len() == 1 { "unique" } else { "ambig" },
list.join(" ")
);
}
}

View File

@@ -0,0 +1,53 @@
//! How many copies of a captured buffer does a container hold?
//!
//! The twins showed that two resources decoding to one buffer can mean the
//! container really holds two (mirrored) copies and our scan found only one.
//! This asks that question for any draw: give it a `vbase`, and it reports every
//! offset whose leading vertices match the draw's dumped positions — directly, and
//! X-mirrored.
//!
//! Usage: locate_draw <container.xpr> <capture.log> <vbase-hex>...
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let text = std::fs::read_to_string(&a[2]).expect("log");
let mut draws = parse_capture(&text);
if draws.is_empty() {
draws = parse_drawlog(&text);
}
let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
for want in &a[3..] {
let vb = u32::from_str_radix(want.trim_start_matches("0x"), 16).expect("hex vbase");
let Some(d) = draws.iter().find(|d| d.vbase == vb) else {
println!("vbase 0x{vb:08X}: not in this log");
continue;
};
let n = d.pos.len().min(8);
let mut direct = Vec::new();
let mut mirror = Vec::new();
for o in (0..bytes.len().saturating_sub(12 + 64 * 24)).step_by(4) {
for (flip, out) in [(1.0f32, &mut direct), (-1.0f32, &mut mirror)] {
let hit = (0..n).all(|k| {
let at = o + k * 24;
(be(at) - flip * d.pos[k][0]).abs() <= 1e-4
&& (be(at + 4) - d.pos[k][1]).abs() <= 1e-4
&& (be(at + 8) - d.pos[k][2]).abs() <= 1e-4
});
if hit {
out.push(o);
}
}
}
println!(
"vbase 0x{vb:08X} vcount={}: {} direct copy/copies {:x?}, {} mirrored {:x?}",
d.vcount,
direct.len(),
&direct[..],
mirror.len(),
&mirror[..]
);
}
}

View File

@@ -0,0 +1,86 @@
//! Which container should the next runtime capture aim at?
//!
//! The 2026-08-13 stage-05 capture showed that a mission keeps several containers
//! resident and that its OWN `Stage_SNN.xpr` is among them (the f101 ACROPOLIS was
//! drawn from `Stage_S05.xpr`), so a capture can be aimed by choosing the mission.
//! This ranks the targets: for every XBG7 resource on the disc it records the
//! containers where it decodes and the ones where it does not, then reports
//!
//! * resources that decode **nowhere** — the real gaps, where a capture is the
//! only ground truth left;
//! * per container, how many of those it holds (fly that mission);
//! * resources that miss in one container but decode in another — a defect worth
//! fixing, but the geometry is already recoverable, so a capture is not needed.
//!
//! Usage: miss_targets <resource3d_dir>
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use std::collections::{BTreeMap, BTreeSet};
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
// resource -> (containers where it decodes, containers where it misses)
let mut ok: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut miss: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
let declared: BTreeSet<String> = xbg7_resource_names(&bytes).into_iter().collect();
if declared.is_empty() {
continue;
}
let decoded: BTreeSet<String> =
Xbg7Model::stage_models(&bytes).into_iter().map(|m| m.name).collect();
for n in &declared {
if decoded.contains(n) {
ok.entry(n.clone()).or_default().insert(where_.clone());
} else {
miss.entry(n.clone()).or_default().insert(where_.clone());
}
}
}
let never: BTreeMap<&String, &BTreeSet<String>> =
miss.iter().filter(|(n, _)| !ok.contains_key(*n)).collect();
let recoverable: Vec<&String> = miss.keys().filter(|n| ok.contains_key(*n)).collect();
println!("resources that decode NOWHERE: {}", never.len());
println!("resources that miss somewhere but decode elsewhere: {}\n", recoverable.len());
// Rank containers by how many never-decoding resources they hold.
let mut per_container: BTreeMap<&String, Vec<&String>> = BTreeMap::new();
for (n, wheres) in &never {
for w in wheres.iter() {
per_container.entry(w).or_default().push(n);
}
}
let mut ranked: Vec<_> = per_container.iter().collect();
ranked.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
println!("container never-decoding resources it holds");
for (w, v) in ranked.iter().take(14) {
let sample: Vec<&str> = v.iter().take(6).map(|s| s.as_str()).collect();
println!("{:<26} {:>3} {}", w, v.len(), sample.join(", "));
}
// Exclusives: a never-decoding resource that only ONE container holds is only
// reachable through whatever loads that container.
let excl: Vec<(&&String, &&BTreeSet<String>)> =
never.iter().filter(|(_, w)| w.len() == 1).collect();
println!("\nof the never-decoding, {} live in exactly one container", excl.len());
let mut by_one: BTreeMap<&String, Vec<&String>> = BTreeMap::new();
for (n, w) in &excl {
by_one.entry(w.iter().next().unwrap()).or_default().push(n);
}
let mut b: Vec<_> = by_one.iter().collect();
b.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
for (w, v) in b.iter().take(10) {
println!(" {:<24} {:>3} {}", w, v.len(), v.iter().take(5).map(|s| s.as_str()).collect::<Vec<_>>().join(", "));
}
}

View File

@@ -0,0 +1,20 @@
//! Dump a composite's scene nodes with their scale — the transform the static
//! assembler copies verbatim into a placement.
//! Usage: node_scale <container.xpr> <composite name>
use sylpheed_formats::mesh::scene_world_nodes;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
for n in scene_world_nodes(&bytes, &a[2]) {
// Row norms of `m`: a rotation has all three at 1.0; anything else is a
// scale baked into the matrix, which `s` does not show.
let norm = |r: [f32; 3]| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt();
println!(
"{:<26} s[{:7.3}{:7.3}{:7.3}] |m rows|[{:7.3}{:7.3}{:7.3}] t[{:9.1}{:9.1}{:9.1}]",
n.resource,
n.s[0], n.s[1], n.s[2],
norm(n.m[0]), norm(n.m[1]), norm(n.m[2]),
n.t[0], n.t[1], n.t[2]
);
}
}

View File

@@ -0,0 +1,129 @@
//! How much geometry on the disc was being read one index element late?
//!
//! The pad-scoring fix (2026-08-13) makes `anchor_pool_mesh` choose the pad whose
//! index run is cleanest instead of the first that validates. This measures the
//! blast radius: per container, how many single-block resources now decode from a
//! run that is NOT the old first-match (`pad 0`) one, and how many decoded runs
//! still contain degenerate triangles (the shift signature) afterwards.
//!
//! Usage: pad_shift_audit <resource3d_dir>
use sylpheed_formats::mesh::Xbg7Model;
fn be16(b: &[u8], at: usize) -> u32 {
((b[at] as u32) << 8) | b[at + 1] as u32
}
/// Fraction of triangles whose face normal agrees with the stored normals,
/// folded to `max(na, 1-na)` so both authored windings read as ≈1.
fn winding(idx: &[u32], pos: &[[f32; 3]], nrm: &[[f32; 3]]) -> f32 {
let (mut agree, mut n) = (0usize, 0usize);
for t in idx.chunks_exact(3) {
let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
if a == b || b == c || a == c || a.max(b).max(c) >= pos.len() || a >= nrm.len() {
continue;
}
let e1 = [pos[b][0] - pos[a][0], pos[b][1] - pos[a][1], pos[b][2] - pos[a][2]];
let e2 = [pos[c][0] - pos[a][0], pos[c][1] - pos[a][1], pos[c][2] - pos[a][2]];
let f = [
e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2],
e1[0] * e2[1] - e1[1] * e2[0],
];
let nv = nrm[a];
n += 1;
if f[0] * nv[0] + f[1] * nv[1] + f[2] * nv[2] > 0.0 {
agree += 1;
}
}
if n == 0 {
return 1.0;
}
let fr = agree as f32 / n as f32;
fr.max(1.0 - fr)
}
fn degenerate(idx: &[u32]) -> usize {
idx.chunks_exact(3)
.filter(|t| t[0] == t[1] || t[1] == t[2] || t[0] == t[2])
.count()
}
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let (mut total, mut moved, mut still_degen) = (0usize, 0usize, 0usize);
let mut weak = 0usize;
let mut weak_wind: Vec<(f32, f32, String)> = Vec::new();
let mut worst: Vec<(usize, String, String)> = Vec::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
let mut c_moved = 0usize;
for m in Xbg7Model::stage_models(&bytes) {
// Degeneracy is counted over EVERY sub-mesh (grouped pools included —
// they have their own pad choice); the pad-0 comparison below only
// applies to single-block resources, whose run starts at `vb - 2n`.
for sm in &m.meshes {
let d = degenerate(&sm.indices);
if d > 0 {
still_degen += 1;
worst.push((d, m.name.clone(), where_.clone()));
}
}
if m.meshes.len() != 1 {
continue;
}
let sm = &m.meshes[0];
let Some(vb) = sm.vbuf_offset else { continue };
let n = sm.indices.len();
if n < 6 || vb < n * 2 {
continue;
}
total += 1;
let pad0: Vec<u32> = (0..n).map(|k| be16(&bytes, vb - n * 2 + k * 2)).collect();
if pad0 != sm.indices {
moved += 1;
c_moved += 1;
// How strong was the evidence for moving? A pad-0 run with
// degenerate triangles is a decisive shift signature; one with
// none moved on winding alone, which is weaker.
if degenerate(&pad0) == 0 {
weak += 1;
// Record how far apart the two runs' winding is, to see
// whether these look like the same shift signature as the
// decisive cases (pad-0 winding drifting toward 0.5) or like
// noise between two equally clean readings.
let w0 = winding(&pad0, &sm.positions, &sm.normals);
let w2: f32 = {
let l2: Vec<u32> = (0..n).map(|k| be16(&bytes, vb - n * 2 - 2 + k * 2)).collect();
winding(&l2, &sm.positions, &sm.normals)
};
weak_wind.push((w0, w2, m.name.clone()));
}
}
}
if c_moved > 0 {
println!("{where_:<24} {c_moved} resources read from a non-pad-0 index run");
}
}
println!("\n{moved} of {total} single-block resources moved off the pad-0 run");
println!("{still_degen} decoded runs still contain a degenerate triangle");
println!("of the moved, {weak} had a pad-0 run with no degenerate triangle (moved on winding alone)");
let clear = weak_wind.iter().filter(|(w0, w2, _)| *w2 - *w0 > 0.15).count();
let close = weak_wind.iter().filter(|(w0, w2, _)| (*w2 - *w0).abs() <= 0.05).count();
println!(" of those: {clear} show the shift signature (pad-2 winding > pad-0 by >0.15), {close} are within 0.05 (noise)");
for (w0, w2, n) in weak_wind.iter().take(8) {
println!(" {n}: pad0 wind {w0:.3} -> pad2 {w2:.3}");
}
worst.sort_by_key(|(d, _, _)| std::cmp::Reverse(*d));
for (d, n, w) in worst.iter().take(15) {
println!(" {n} ({w}): {d} degenerate triangles");
}
}

View File

@@ -0,0 +1,13 @@
fn main(){
let a:Vec<String>=std::env::args().collect();
let bytes=std::fs::read(&a[1]).unwrap();
let filter=a.get(2).cloned().unwrap_or_default();
for n in sylpheed_formats::mesh::xbg7_resource_names(&bytes) {
if !filter.is_empty() && !n.contains(&filter) { continue }
if let Some((m,stride))=sylpheed_formats::mesh::debug_resource_params(&bytes,&n) {
let strides = sylpheed_formats::mesh::debug_decl_strides(&bytes, &n);
println!("{n:<28} decl-stride {stride} markers {:?} per-sub-mesh strides {:?}",
&m[..m.len().min(4)], strides);
}
}
}

View File

@@ -0,0 +1,29 @@
//! Raw token sequence around a key, for one record — the ground truth for
//! "is this field actually defaulted, or is our reader missing it?"
//! Run: pool_window <disc-root> <ID substring> <key>
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
fn main() {
let disc = std::env::args().nth(1).unwrap();
let want = std::env::args().nth(2).unwrap();
let key = std::env::args().nth(3).unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
for e in pak.entries() {
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
let Some(id) = o.get_raw("ID") else { continue };
if !id.contains(&want) { continue; }
let t = o.tokens();
println!("=== {id} get_f32({key}) = {:?}", o.get_f32(&key));
for (i, tok) in t.iter().enumerate() {
if tok == &key {
let lo = i.saturating_sub(6);
let hi = (i + 7).min(t.len());
for j in lo..hi {
println!(" [{j}]{} {:?}", if j == i { " <-- key" } else { " " }, t[j]);
}
println!();
}
}
}
}

View File

@@ -0,0 +1,6 @@
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
let vb: usize = a[3].parse().unwrap();
println!("{:?}", sylpheed_formats::mesh::debug_try_anchor(&bytes, &a[2], vb, 3));
}

View File

@@ -0,0 +1,60 @@
//! Which mission should the next unit harvest fly?
//!
//! Unit definitions are instantiated per stage, so coverage grows by visiting
//! missions — but only if the mission fields units we have not read yet. This
//! ranks every stage by how many of its roster units are missing from the
//! harvested CSV.
//!
//! Usage: roster_target <disc-root> <unit-runtime-fields.csv>
use sylpheed_formats::hash::name_hash;
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
use std::collections::{BTreeMap, BTreeSet};
fn main() {
let a: Vec<String> = std::env::args().collect();
let pak = PakArchive::open(format!("{}/dat/GP_MAIN_GAME_E.pak", a[1])).expect("pak");
let have: BTreeSet<String> = std::fs::read_to_string(&a[2])
.expect("csv")
.lines()
.skip(1)
.filter_map(|l| l.split(',').next().map(str::to_string))
.collect();
// Label each roster by STAGE: the TOC stores a hash of the original path, and
// the table names are known (`EnumUnit_SNN.tbl`), so hashing the candidates
// maps entries back to stages. Most rosters carry no `UN_S<NN>_` prop id, which
// is all `UnitRoster::stage` can infer from.
let mut by_hash: BTreeMap<u32, String> = BTreeMap::new();
// The TOC hashes a PATH, and these tables live under a directory — try the
// known schemes (see hash::TOC_NAME_SCHEMES) rather than the bare name.
for i in 1..=29 {
let stage = format!("S{i:02}");
for pre in ["", "unit\\", "battle\\", "stage\\", "enemy\\"] {
for suf in [".tbl", ""] {
by_hash.insert(name_hash(&format!("{pre}EnumUnit_{stage}{suf}")), stage.clone());
}
}
}
let mut rows: Vec<(usize, String, Vec<String>)> = Vec::new();
for e in pak.entries() {
let Some(stage) = by_hash.get(&e.name_hash).cloned() else { continue };
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
let mut units: Vec<String> = Vec::new();
for id in o.tokens().iter().filter(|s| s.starts_with("UN_")) {
let prop = id.contains("Asteroid") || id.contains("cmesh") || id.contains("_Box");
if !prop && !units.iter().any(|u| u == id) {
units.push(id.clone());
}
}
let missing: Vec<String> = units.iter().filter(|u| !have.contains(*u)).cloned().collect();
rows.push((missing.len(), stage, missing));
}
rows.sort_by_key(|(n, _, _)| std::cmp::Reverse(*n));
println!("units already harvested: {}\n", have.len());
println!("stage missing roster units not yet read");
for (n, stage, missing) in rows.iter().take(12) {
let s: Vec<&str> = missing.iter().take(6).map(|s| s.as_str()).collect();
println!("{stage:<8} {n:>5} {}", s.join(", "));
}
}

View File

@@ -0,0 +1,18 @@
//! RE probe: dump tables.pak screen-config records that mention a given token.
//! Usage: screen_configs <disc-root> <substring>
use sylpheed_formats::{idxd::IdxdObject, pak::PakArchive};
fn main(){
let a:Vec<String>=std::env::args().collect();
let needle=a.get(2).cloned().unwrap_or_else(||"CHALLENGE".into());
let arc=PakArchive::open(format!("{}/dat/tables.pak",a[1])).unwrap();
for (i,e) in arc.entries().iter().enumerate(){
let Ok(b)=arc.read(e) else{continue};
let Ok(o)=IdxdObject::parse(&b) else{continue};
let t=o.tokens();
if !t.iter().any(|s|s.to_lowercase().contains(&needle.to_lowercase())){continue}
let path=t.iter().find(|s|s.contains(".pak+")).cloned().unwrap_or_default();
if !path.is_empty() && !path.contains("+eng"){continue}
println!("\n=== entry #{i} schema {:08x} [{path}] {} tokens ===",o.schema_hash,t.len());
for (j,tok) in t.iter().enumerate(){println!(" {j:3} {tok}");}
}
}

View File

@@ -0,0 +1,111 @@
//! Dump a UI screen's full layout from one RATC bundle: the element declaration
//! table (what is drawn, in back-to-front order, with pivots and parent links)
//! plus the placement region that follows it (per element: a keyframe group of
//! scale / tint / X / Y).
//!
//! See docs/re/structures/ui-rat-layout.md. Run:
//! cargo run -p sylpheed-formats --example screen_layout -- <PAK> [0xHASH]
//! With no hash, the largest RATC entry in the pak is used.
use sylpheed_formats::pak::PakArchive;
fn be32(b: &[u8], o: usize) -> u32 {
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
fn main() {
let mut args = std::env::args().skip(1);
let pak = args.next().expect("usage: screen_layout <pak> [0xHASH]");
let want = args.next();
let arc = PakArchive::open(&pak).expect("open pak");
let bytes = match want {
Some(h) if h.starts_with("0x") => {
let h = u32::from_str_radix(h.trim_start_matches("0x"), 16).expect("hash");
arc.read_by_hash(h).expect("entry").expect("decompress")
}
Some(name) => arc.read_by_name(&name).expect("entry present").expect("decompress"),
None => arc
.entries()
.iter()
.filter_map(|e| arc.read(e).ok())
.filter(|b| b.len() > 16 && &b[..4] == b"RATC")
.max_by_key(|b| b.len())
.expect("no RATC entry"),
};
assert_eq!(&bytes[..4], b"RATC", "not a RATC bundle");
let count = be32(&bytes, 0x14) as usize;
let mut names = Vec::with_capacity(count);
let mut meta = Vec::with_capacity(count);
for i in 0..count {
let e = &bytes[0x20 + i * 60..0x20 + (i + 1) * 60];
let name = String::from_utf8_lossy(&e[..28])
.trim_end_matches('\0')
.trim_end_matches(char::from(0))
.to_string();
let parent = be32(e, 32);
let kind = be32(e, 40);
let (px, py) = (be32(e, 48), be32(e, 52));
names.push(name);
meta.push((parent, kind, px, py));
}
// Placement region: groups of (u32 element index, u32 keyframe count) then
// `count` 40-byte keyframes; the X/Y sit 12 bytes into the scale/tint block.
let mut pos = 0x20 + count * 60;
// X/Y are SIGNED: off-screen animation starts are negative (e.g. -516).
let mut placements: Vec<Vec<(i32, i32, u32)>> = vec![Vec::new(); count];
for _ in 0..count {
if pos + 8 > bytes.len() { break }
let idx = be32(&bytes, pos) as usize;
let frames = be32(&bytes, pos + 4) as usize;
if idx >= count || frames == 0 || frames > 4096 { break }
let first = pos + 28; // header + lead-in, verified on the pause bundles
let mut group = Vec::with_capacity(frames);
for k in 0..frames {
let blk = first + k * 40;
if blk + 20 > bytes.len() { break }
group.push((be32(&bytes, blk + 12) as i32, be32(&bytes, blk + 16) as i32, be32(&bytes, blk + 20)));
}
placements[idx] = group;
pos = first + frames * 40 - 20;
}
println!("{} elements", count);
println!("{:<3} {:<30} {:>7} {:>8} {:>12} {:>4} placement", "#", "element", "parent", "kind", "pivot", "kf");
for i in 0..count {
let (parent, kind, px, py) = meta[i];
let p = &placements[i];
// A group is an in → hold → out animation, so neither the first nor the
// last keyframe is where the element sits: report the MAX-DWELL one
// (longest gap to the next keyframe's time).
let shown = if p.is_empty() {
"".into()
} else if p.len() == 1 {
format!("({},{})", p[0].0, p[0].1)
} else {
let mut best = (0usize, 0u32);
for k in 0..p.len() - 1 {
let d = p[k + 1].2.saturating_sub(p[k].2);
if d > best.1 { best = (k, d) }
}
let r = p[best.0];
format!(
"rest ({},{}) t={}..{} [{}]",
r.0, r.1, r.2, p[best.0 + 1].2,
p.iter().map(|(x, y, t)| format!("{t}:{x},{y}")).collect::<Vec<_>>().join(" ")
)
};
println!(
"{:<3} {:<30} {:>7} {:>8} {:>12} {:>4} {}",
i,
names[i],
if parent == u32::MAX { "-".into() } else { parent.to_string() },
format!("{kind:#x}"),
format!("({px},{py})"),
p.len(),
shown
);
}
}

View File

@@ -0,0 +1,262 @@
//! Ask the runtime capture whether two resources that our decoder gives the
//! **same geometry** really are the same geometry.
//!
//! Our XBG7 anchor scan sometimes lands two different resource names on one
//! vertex buffer. Statics cannot separate "the container genuinely reuses a
//! buffer" from "the scan picked the wrong candidate" — but a capture can: the
//! engine uploads a buffer per resource and reuses one only 3.4 % of the time
//! (see docs/re/structures/xbg7-mesh.md), so a group of `k` resources our
//! decoder collapses onto one buffer should show up as `k` distinct `vbase`s
//! carrying that same vertex count and those same positions. Fewer means at
//! most one member of the group is really that geometry.
//!
//! Usage:
//! cargo run --release --example shared_vbase_check -- \
//! <Stage_SNN.xpr> <capture.log>...
use sylpheed_formats::mesh::Xbg7Model;
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog, CapturedDraw};
use std::collections::{BTreeMap, BTreeSet};
/// Quantised position key — the logs print 4 decimals, so compare at that scale.
fn key(p: [f32; 3]) -> (i64, i64, i64) {
(
(p[0] as f64 * 1e4).round() as i64,
(p[1] as f64 * 1e4).round() as i64,
(p[2] as f64 * 1e4).round() as i64,
)
}
/// Where in the container does a captured buffer live? POSITION is `f32×3` big
/// endian at vertex offset 0, so a draw's dumped positions are a literal byte
/// pattern: find the first one, then confirm the next few at a fixed stride.
/// This turns a capture into ground truth for a resource we mis-anchored.
fn locate_run(bytes: &[u8], pos: &[[f32; 3]]) -> Vec<(usize, usize)> {
if pos.len() < 4 {
return Vec::new();
}
// The log prints 4 decimals, so match on value with the printing tolerance
// rather than on bytes.
let be = |b: &[u8], at: usize| f32::from_be_bytes(b[at..at + 4].try_into().unwrap());
let same = |b: &[u8], at: usize, p: [f32; 3]| {
at + 12 <= b.len() && (0..3).all(|c| (be(b, at + c * 4) - p[c]).abs() <= 1e-4)
};
let mut out = Vec::new();
for o in (0..bytes.len().saturating_sub(12)).step_by(4) {
if !same(bytes, o, pos[0]) {
continue;
}
for stride in (12..=64).step_by(4) {
if (1..4).all(|k| same(bytes, o + k * stride, pos[k])) {
out.push((o, stride));
break;
}
}
}
out
}
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: shared_vbase_check <Stage_SNN.xpr> <capture.log>...");
std::process::exit(2);
}
let bytes = std::fs::read(&args[1]).expect("read container");
// Every draw from every log, keyed by vertex count.
// Keep the logs apart: each is its own emulator run, so a `vbase` only
// means something within one log.
let mut logs: Vec<(String, Vec<CapturedDraw>)> = Vec::new();
for log in args[2..].iter().filter(|a| !a.starts_with("--")) {
let text = std::fs::read_to_string(log).expect("read log");
let mut d = parse_capture(&text);
if d.is_empty() {
d = parse_drawlog(&text);
}
eprintln!("{log}: {} draws", d.len());
logs.push((log.rsplit('/').next().unwrap_or(log).to_string(), d));
}
// `--map`: is a draw's guest `vbase` just the container file offset plus a
// constant? If the container is uploaded contiguously it is — and then a
// capture names the exact offset of every buffer the engine drew, which is
// ground truth the anchor scan currently has to guess at.
if args.iter().any(|a| a == "--map") {
for (log, draws) in &logs {
let mut seen: BTreeSet<u32> = BTreeSet::new();
let mut delta: BTreeMap<i64, usize> = BTreeMap::new();
let mut unfound = 0usize;
for d in draws {
if d.pos.len() < 8 || d.vcount < 20 || !seen.insert(d.vbase) {
continue;
}
let at = locate_run(&bytes, &d.pos);
if at.is_empty() {
unfound += 1;
continue;
}
for (o, _) in at {
*delta.entry(d.vbase as i64 - o as i64).or_default() += 1;
}
}
let mut top: Vec<_> = delta.iter().collect();
top.sort_by_key(|(_, n)| std::cmp::Reverse(**n));
println!("{log}: {} distinct vbases located, {unfound} not in this container", seen.len() - unfound);
for (d, n) in top.iter().take(5) {
println!(" vbase - offset = 0x{:X} ×{n}", d);
}
}
return;
}
// Decode the container and group resources by the exact geometry they got.
let models = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false);
// `--truth <base>`: with the container's guest load address (from `--map`),
// every draw names a file offset. Print it against the offset our anchor
// scan chose for each resource — a direct read-out of what we got wrong.
if let Some(a) = args.iter().find_map(|a| a.strip_prefix("--truth=")) {
let base = u32::from_str_radix(a.trim_start_matches("0x"), 16).expect("base");
// Where the anchor scan actually put each sub-mesh — exact, from the
// decoder, not inferred by searching for its leading vertices (the same
// leading run occurs at several offsets in a container, so a search
// cannot tell where a resource was anchored).
let mut ours: BTreeMap<usize, Vec<(String, usize)>> = BTreeMap::new();
for m in &models {
for sub in &m.meshes {
if let Some(o) = sub.vbuf_offset {
ours.entry(o).or_default().push((m.name.clone(), sub.positions.len()));
}
}
}
if args.iter().any(|a| a == "--anchors") {
println!("{:<12} where our decode put each resource", "file offset");
for (o, v) in &ours {
for (n, c) in v {
println!("0x{o:<10x} {n} ({c} verts)");
}
}
return;
}
let mut drawn: BTreeMap<usize, u32> = BTreeMap::new();
for (_, draws) in &logs {
for d in draws {
let off = d.vbase.wrapping_sub(base) as usize;
if off < bytes.len() && d.vcount >= 20 {
drawn.insert(off, d.vcount);
}
}
}
// Which resources have the drawn vertex count, wherever we put them?
// Right size + wrong place is a different bug from never finding it.
let mut by_count: BTreeMap<usize, Vec<String>> = BTreeMap::new();
for m in &models {
let n: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
by_count.entry(n).or_default().push(m.name.clone());
}
// Is a capture-proven offset even a candidate the scan considers?
// Absent ⇒ the run scan misses it; present ⇒ selection picked another.
let starts: BTreeSet<usize> =
sylpheed_formats::mesh::debug_vertex_run_starts(&bytes, 24).into_iter().collect();
eprintln!("{} stride-24 candidate starts in this container", starts.len());
println!("{:<12} {:>7} {:>9} {:<44} our resources with that vcount", "file offset", "vcount", "candidate", "claimed by our decode");
for (off, vcount) in &drawn {
let who = ours
.get(off)
.map(|v| {
v.iter().map(|(n, c)| format!("{n}({c})")).collect::<Vec<_>>().join(", ")
})
.unwrap_or_else(|| "— NOBODY".into());
let same = by_count
.get(&(*vcount as usize))
.map(|v| v.join(", "))
.unwrap_or_else(|| "— none".into());
// Nearest resource we anchored at or before this offset — the
// likely owner of a buffer nobody claims.
let near = ours
.range(..=*off)
.next_back()
.map(|(o, v)| format!("{} @ -0x{:x}", v[0].0, off - o))
.unwrap_or_default();
let cand = if starts.contains(off) { "yes" } else { "NO" };
println!("0x{off:<10x} {vcount:>7} {cand:>9} {who:<44} {same:<34} {near}");
}
return;
}
let mut groups: BTreeMap<Vec<(i64, i64, i64)>, Vec<String>> = BTreeMap::new();
for m in &models {
let pos: Vec<(i64, i64, i64)> =
m.meshes.iter().flat_map(|s| s.positions.iter().copied()).map(key).collect();
if pos.is_empty() {
continue;
}
groups.entry(pos).or_default().push(m.name.clone());
}
let shared: Vec<_> = groups.iter().filter(|(_, n)| n.len() > 1).collect();
eprintln!(
"{} models, {} distinct geometries, {} shared by >1 resource",
models.len(),
groups.len(),
shared.len()
);
for (pos, names) in shared {
let vcount = pos.len() as u32;
// A draw belongs to this geometry if every dumped position is one of
// the decoded ones (the log dumps at most the first 64).
let want: BTreeSet<(i64, i64, i64)> = pos.iter().copied().collect();
println!("\n{} ({vcount} verts, {} resources)", names.join(""), names.len());
for (log, draws) in &logs {
let hits: Vec<&CapturedDraw> = draws.iter().filter(|d| d.vcount == vcount).collect();
let all: BTreeSet<u32> = hits.iter().map(|d| d.vbase).collect();
let matching: Vec<&&CapturedDraw> = hits
.iter()
.filter(|d| !d.pos.is_empty() && d.pos.iter().all(|p| want.contains(&key(*p))))
.collect();
let ok: BTreeSet<u32> = matching.iter().map(|d| d.vbase).collect();
// A buffer we do NOT match may still be the mirrored twin: same
// geometry with x negated. That is the case our assembler papers
// over with `apply_twin_mirrors`.
let mirrored: BTreeSet<u32> = hits
.iter()
.filter(|d| !ok.contains(&d.vbase))
.filter(|d| {
!d.pos.is_empty()
&& d.pos.iter().all(|p| want.contains(&key([-p[0], p[1], p[2]])))
})
.map(|d| d.vbase)
.collect();
println!(
" {log:32} draws={:<5} vbases@vcount={:<3} ours={} mirrored={} other={}",
hits.len(),
all.len(),
ok.len(),
mirrored.len(),
all.len() - ok.len() - mirrored.len()
);
// Where does each captured buffer live in the container? One
// representative draw per vbase is enough.
let mut done: BTreeSet<u32> = BTreeSet::new();
for d in &hits {
if d.pos.len() < 8 || !done.insert(d.vbase) {
continue;
}
let kind = if ok.contains(&d.vbase) {
"ours"
} else if mirrored.contains(&d.vbase) {
"mirror"
} else {
"other"
};
let at = locate_run(&bytes, &d.pos);
let shown: Vec<String> =
at.iter().take(4).map(|(o, s)| format!("0x{o:x}/stride{s}")).collect();
println!(
" vbase=0x{:08X} [{kind:6}] in container at: {}",
d.vbase,
if shown.is_empty() { "NOT FOUND".into() } else { shown.join(" ") }
);
}
}
}
}

View File

@@ -0,0 +1,74 @@
//! Screen every ship family for a part that is wildly out of scale with its
//! siblings — the "slab" signature of a mis-anchored block.
//!
//! Rendering `e106` found such a part (`bdy_03`, 600×1600×998 beside parts of
//! ~250) that coverage, cross-container consistency, the capture oracle and the
//! twin invariant were all blind to. Eyeballing does not scale to 166 containers;
//! this does the same comparison numerically.
//!
//! Usage: slab_screen <resource3d_dir> [factor]
use sylpheed_formats::mesh::Xbg7Model;
use sylpheed_formats::ship::{is_base_part, ship_id_of};
use std::collections::BTreeMap;
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let factor: f32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(4.0);
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let mut flagged = 0usize;
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let mut by_ship: BTreeMap<String, Vec<(String, f32)>> = BTreeMap::new();
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
if !is_base_part(&m.name) {
continue;
}
let Some(id) = ship_id_of(&m.name) else { continue };
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for s in &m.meshes {
for q in &s.positions {
for k in 0..3 {
lo[k] = lo[k].min(q[k]);
hi[k] = hi[k].max(q[k]);
}
}
}
if lo[0] == f32::MAX {
continue;
}
// Compare the part's SMALLEST axis span, not its diagonal. A long
// thin part (an antenna, a 100 000-unit tether on `f002`) is
// legitimately huge in one axis and would swamp a diagonal test; a
// mis-anchored block is bulky in all three, which is what the e106
// slab looked like (600×1600×998 beside siblings of ~250).
let thin = (hi[0] - lo[0]).min(hi[1] - lo[1]).min(hi[2] - lo[2]);
by_ship.entry(id.to_string()).or_default().push((m.name.clone(), thin));
}
for (id, parts) in &by_ship {
if parts.len() < 3 {
continue; // no meaningful median
}
let mut d: Vec<f32> = parts.iter().map(|(_, x)| *x).collect();
d.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median = d[d.len() / 2];
for (name, diag) in parts {
if *diag > median * factor {
flagged += 1;
println!(
"{:<22} {name:<22} min-axis {diag:>8.0} vs ship median {median:>8.0} ({:.1}×)",
f.file_name().unwrap().to_string_lossy(),
diag / median
);
}
}
}
}
println!("{flagged} parts flagged at {factor}× the ship median");
}

View File

@@ -0,0 +1,13 @@
use sylpheed_formats::mesh::{debug_resource_params, debug_vertex_run_starts};
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
let (markers, stride) = debug_resource_params(&bytes, &a[2]).expect("resource");
println!("{}: stride={stride} markers={markers:?}", a[2]);
let starts = debug_vertex_run_starts(&bytes, stride);
println!("{} candidate starts at stride {stride}", starts.len());
for off in &a[3..] {
let o = usize::from_str_radix(off.trim_start_matches("0x"), 16).unwrap();
println!(" 0x{o:x} in candidate list: {}", starts.binary_search(&o).is_ok());
}
}

View File

@@ -0,0 +1,7 @@
fn main(){let a:Vec<String>=std::env::args().collect();let b=std::fs::read(&a[1]).unwrap();
let stride:usize=a[2].parse().unwrap(); let want:usize=a[3].parse().unwrap();
let s=sylpheed_formats::mesh::debug_vertex_run_starts(&b,stride);
println!("{} candidate starts at stride {}", s.len(), stride);
println!("contains {:#x}: {}", want, s.contains(&want));
let near:Vec<String>=s.iter().filter(|&&o| o.abs_diff(want)<0x200).map(|o|format!("{o:#x}")).collect();
println!("nearby: {}", near.join(" "));}

View File

@@ -0,0 +1,34 @@
//! Per-sub-mesh vertex/index/coverage dump for one resource.
//! Usage: submesh_dump <container.xpr> <resource>...
use sylpheed_formats::mesh::{debug_resource_params, Xbg7Model};
use std::collections::HashSet;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let want: HashSet<String> = a[2..].iter().cloned().collect();
for m in Xbg7Model::models_named(&bytes, &want, &|| false) {
let markers = debug_resource_params(&bytes, &m.name).map(|(mk, _)| mk).unwrap_or_default();
println!("{}{} sub-meshes decoded, {} markers declared", m.name, m.meshes.len(), markers.len());
for (i, s) in m.meshes.iter().enumerate() {
let max_idx = s.indices.iter().max().copied().unwrap_or(0) as usize;
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for q in &s.positions {
for k in 0..3 {
lo[k] = lo[k].min(q[k]);
hi[k] = hi[k].max(q[k]);
}
}
println!(
" #{i:<2} at 0x{:<9x} verts {:<6} idx {:<6} max_idx {:<6} slack {} span [{:.0} {:.0} {:.0}]",
s.vbuf_offset.unwrap_or(0),
s.positions.len(),
s.indices.len(),
max_idx,
s.positions.len() as i64 - 1 - max_idx as i64,
hi[0] - lo[0],
hi[1] - lo[1],
hi[2] - lo[2]
);
}
}
}

View File

@@ -0,0 +1,17 @@
//! Does the capture-proven offset validate for the resource that should own it?
use sylpheed_formats::mesh::debug_try_anchor;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
// The production scan tries pads 0..=3; pass a bigger one to ask whether the
// block would validate at all with a wider index/vertex gap.
let max_pad: usize = std::env::var("MAX_PAD").ok().and_then(|v| v.parse().ok()).unwrap_or(3);
for pair in a[2..].iter() {
let (name, off) = pair.split_once('@').unwrap();
let off = usize::from_str_radix(off.trim_start_matches("0x"), 16).unwrap();
match debug_try_anchor(&bytes, name, off, max_pad) {
Some((v, i, pad)) => println!("{name:22} @ 0x{off:x} ACCEPTED v={v} idx={i} pad={pad}"),
None => println!("{name:22} @ 0x{off:x} rejected"),
}
}
}

View File

@@ -0,0 +1,119 @@
//! Do port/starboard twins decode to mirror images of each other?
//!
//! A runtime capture proved the container stores both halves of the `e106` hull
//! as separate, X-reflected buffers. That gives a **capture-free invariant**:
//! a `…_01`/`…_02` pair of equal vertex count should decode to geometry that is
//! an exact X-mirror — never to identical geometry (that is the collapse the
//! distinct-assignment fix targets), and never to something unrelated (that is a
//! mis-anchor no count-based metric can see).
//!
//! Usage: twin_mirror_audit <resource3d_dir>
use sylpheed_formats::mesh::Xbg7Model;
use std::collections::BTreeMap;
fn key(p: [f32; 3]) -> (i64, i64, i64) {
(
(p[0] * 1e3).round() as i64,
(p[1] * 1e3).round() as i64,
(p[2] * 1e3).round() as i64,
)
}
/// Elementwise equality with a tolerance. Truncating keys is too strict for a
/// mirrored pair: the halves are authored, not bit-negated, so they differ in
/// the last digits and an exact key test reports them as unrelated.
fn near(p: [f32; 3], q: [f32; 3]) -> bool {
(0..3).all(|c| (p[c] - q[c]).abs() <= 1e-3 * (1.0 + q[c].abs()))
}
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let (mut same, mut mirrored, mut unrelated, mut related) = (0usize, 0usize, 0usize, 0usize);
let mut examples: Vec<String> = Vec::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let models = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false);
let by_name: BTreeMap<&str, &Xbg7Model> =
models.iter().map(|m| (m.name.as_str(), m)).collect();
for m in &models {
let Some(stem) = m.name.strip_suffix("_01") else { continue };
let Some(t) = by_name.get(format!("{stem}_02").as_str()) else { continue };
let a: Vec<[f32; 3]> = m.meshes.iter().flat_map(|s| s.positions.clone()).collect();
let b: Vec<[f32; 3]> = t.meshes.iter().flat_map(|s| s.positions.clone()).collect();
if a.len() != b.len() || a.is_empty() {
continue;
}
let ident = a.iter().zip(&b).all(|(p, q)| near(*p, *q));
let mirr = a.iter().zip(&b).all(|(p, q)| near([-p[0], p[1], p[2]], *q));
// A pair that is neither may still be RELATED: mirrored on another
// axis, or the same point cloud in a different vertex order. Only a
// pair that is none of these is evidence of a mis-anchor.
let mirr_y = a.iter().zip(&b).all(|(p, q)| near([p[0], -p[1], p[2]], *q));
let mirr_z = a.iter().zip(&b).all(|(p, q)| near([p[0], p[1], -p[2]], *q));
let set = |v: &Vec<[f32; 3]>| {
let mut s: Vec<_> = v.iter().map(|p| key(*p)).collect();
s.sort_unstable();
s
};
let same_cloud = set(&a) == set(&b);
// …and the same point cloud after mirroring, for a pair whose
// halves are authored in different vertex order.
let mirrored_cloud = {
let am: Vec<[f32; 3]> = a.iter().map(|p| [-p[0], p[1], p[2]]).collect();
set(&am) == set(&b)
};
if !ident && !mirr && (mirr_y || mirr_z || same_cloud || mirrored_cloud) {
related += 1;
continue;
}
// Identical GEOMETRY is only a collapse if it also comes from the
// same buffer: a container may store a part twice unmirrored, and
// then two twins on two copies decode identically and correctly.
let shared_buffer = m.meshes[0].vbuf_offset.is_some()
&& m.meshes[0].vbuf_offset == t.meshes[0].vbuf_offset;
if ident && !shared_buffer {
related += 1;
continue;
}
if ident {
same += 1;
if examples.len() < 6 {
examples.push(format!(
"identical: {} / {}_02 in {}",
m.name,
stem,
f.file_name().unwrap().to_string_lossy()
));
}
} else if mirr {
mirrored += 1;
} else {
unrelated += 1;
if examples.len() < 6 {
examples.push(format!(
"unrelated: {} / {}_02 in {}",
m.name,
stem,
f.file_name().unwrap().to_string_lossy()
));
}
}
}
}
println!("twin pairs of equal vertex count: {}", same + mirrored + unrelated + related);
println!(" exact X-mirror (expected) : {mirrored}");
println!(" IDENTICAL, one buffer (collapse) : {same}");
println!(" related other way (Y/Z mirror, reordered): {related}");
println!(" unrelated (mis-anchor?) : {unrelated}");
for e in examples {
println!(" e.g. {e}");
}
}

View File

@@ -0,0 +1,56 @@
//! Which XBG7 resources never decode, and how big is that population?
//!
//! Coverage has been reported as "resources decoded" without a denominator. This
//! prints both, per container and in total, and names the misses so the gate
//! attribution (`why_rejected`) has a work list.
use sylpheed_formats::mesh::{debug_resource_params, xbg7_resource_names, Xbg7Model};
use std::collections::HashSet;
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let show = std::env::args().nth(2).is_some();
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let (mut total, mut decoded, mut no_decl) = (0usize, 0usize, 0usize);
let mut misses: Vec<String> = Vec::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let names = xbg7_resource_names(&bytes);
if names.is_empty() {
continue;
}
let got: HashSet<String> = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false)
.into_iter()
.map(|m| m.name)
.collect();
for n in &names {
total += 1;
if got.contains(n) {
decoded += 1;
} else if debug_resource_params(&bytes, n).is_none() {
// No vertex declaration / no index markers: the anchor scan
// never even considers these, so they are a different question
// from "searched and not found".
no_decl += 1;
} else {
misses.push(format!("{}|{n}", f.file_name().unwrap().to_string_lossy()));
}
}
}
println!(
"XBG7 resources: {total} total, {decoded} decoded ({:.1}%), {no_decl} without a usable descriptor, {} searched-and-missed",
100.0 * decoded as f64 / total as f64,
misses.len()
);
if show {
for m in &misses {
println!("{m}");
}
}
}

View File

@@ -0,0 +1,31 @@
//! Every key/value a named unit record actually sets on disc.
//!
//! The live definition object in guest RAM has no field names; to find where a
//! field like `HQRatio` sits inside it, anchor on a unit whose disc record DOES
//! set that field and look for the value. This prints those anchors.
//! Run: unit_fields <disc-root> <substring of ID>...
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
fn main() {
let disc = std::env::args().nth(1).expect("disc root");
let want: Vec<String> = std::env::args().skip(2).collect();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).expect("pak");
for entry in pak.entries() {
let Ok(bytes) = pak.read(entry) else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
let Some(id) = obj.get_raw("ID") else { continue };
if !want.iter().any(|w| id.contains(w.as_str())) {
continue;
}
println!("=== {id} (schema {:08x})", obj.schema_hash);
// The pool interleaves VALUE before KEY (see the module docs), so the
// pairs read (t[i] = value, t[i+1] = key).
let t = obj.tokens();
let mut i = 0;
while i + 1 < t.len() {
println!(" {:<30} {}", t[i + 1], t[i]);
i += 2;
}
}
}

View File

@@ -0,0 +1,33 @@
//! (HP, Size_X, Size_Y, Size_Z) for every craft unit and vessel on the disc.
//!
//! A live definition object in guest RAM carries HP at `+0x54` and the sizes at
//! `+0x30/34/38` but no name in its first words, so identity has to come from the
//! values themselves: this is the lookup table for that match.
//! Run: unit_signatures <disc-root>
use sylpheed_formats::game_data::{load_units, load_vessels};
use sylpheed_formats::PakArchive;
fn main() {
let disc = std::env::args().nth(1).expect("disc root");
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).expect("pak");
println!("{:<34} {:>9} {:>8} {:>8} {:>8} kind", "id", "hp", "size_x", "size_y", "size_z");
for u in load_units(&pak) {
println!(
"{:<34} {:>9} {:>8} {:>8} {:>8} unit",
u.id.unwrap_or_default(),
u.hp.map(|v| v.to_string()).unwrap_or("".into()),
u.size_x.map(|v| v.to_string()).unwrap_or("".into()),
u.size_y.map(|v| v.to_string()).unwrap_or("".into()),
u.size_z.map(|v| v.to_string()).unwrap_or("".into()),
);
}
for v in load_vessels(&pak) {
println!(
"{:<34} {:>9} {:>8} {:>8} {:>8} vessel",
v.id.unwrap_or_default(),
v.hp.map(|x| x.to_string()).unwrap_or("".into()),
v.size_x.map(|x| x.to_string()).unwrap_or("".into()),
v.size_y.map(|x| x.to_string()).unwrap_or("".into()),
v.size_z.map(|x| x.to_string()).unwrap_or("".into()),
);
}
}

View File

@@ -0,0 +1,22 @@
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
fn main() {
let disc = std::env::args().nth(1).unwrap();
let want = std::env::args().nth(2).unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
for e in pak.entries() {
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
let Some(id) = o.get_raw("ID") else { continue };
if !id.contains(&want) { continue; }
println!("=== {id}");
let mut seen = std::collections::BTreeSet::new();
for k in o.tokens() {
if let Some(v) = o.get_f32(k) {
if v != 0.0 && seen.insert(k.clone()) {
println!(" {:<28} {:<12} 0x{:08x}", k, v, v.to_bits());
}
}
}
}
}

View File

@@ -0,0 +1,98 @@
//! Global "which resource has N vertices?" index over every `.xpr` container in
//! an extracted `resource3d` directory, answered for the vcounts a capture log
//! actually drew.
//!
//! Companion to `invert_capture`: that one asks the question inside a single
//! stage container, this one asks it across ALL containers — so a draw whose
//! geometry lives in `Common.xpr`, a `rou_*` weapon pack or a `BG_*` backdrop is
//! still identified instead of coming back "no resource".
//!
//! Usage:
//! cargo run --release --example vcount_index -- <resource3d_dir> <capture.log> [top_n]
//! cargo run --release --example vcount_index -- <resource3d_dir> --vcounts 10891,6000
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
use std::collections::{HashMap, HashSet};
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: vcount_index <resource3d_dir> <capture.log|--vcounts a,b,c> [top_n]");
std::process::exit(2);
}
let dir = &args[1];
// Which vertex counts are we asking about, and how often was each drawn?
let mut draw_count: HashMap<u32, usize> = HashMap::new();
let mut bufs: HashMap<u32, HashSet<u32>> = HashMap::new();
if args[2] == "--vcounts" {
for v in args[3].split(',').filter_map(|s| s.trim().parse::<u32>().ok()) {
draw_count.insert(v, 0);
}
} else {
let text = std::fs::read_to_string(&args[2]).expect("read log");
let mut draws = parse_capture(&text);
if draws.is_empty() {
draws = parse_drawlog(&text);
}
eprintln!("parsed {} draws", draws.len());
for d in &draws {
*draw_count.entry(d.vcount).or_default() += 1;
bufs.entry(d.vcount).or_default().insert(d.vbase);
}
}
let top_n: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(usize::MAX);
// Decode every container once; keep only the vcount → names mapping.
let mut by_vcount: HashMap<u32, Vec<String>> = HashMap::new();
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
.expect("read dir")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|e| e == "xpr"))
.collect();
files.sort();
let mut total_res = 0usize;
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let names = xbg7_resource_names(&bytes);
if names.is_empty() {
continue;
}
let want: HashSet<String> = names.iter().cloned().collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
let container = f.file_stem().unwrap().to_string_lossy().to_string();
for m in &models {
total_res += 1;
let whole: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
by_vcount.entry(whole as u32).or_default().push(format!("{container}:{}", m.name));
if m.meshes.len() > 1 {
for (i, s) in m.meshes.iter().enumerate() {
by_vcount
.entry(s.positions.len() as u32)
.or_default()
.push(format!("{container}:{}#{i}", m.name));
}
}
}
}
eprintln!("indexed {} resources from {} containers", total_res, files.len());
let mut vcounts: Vec<u32> = draw_count.keys().copied().collect();
vcounts.sort_unstable_by(|a, b| b.cmp(a));
println!("\nvcount draws bufs resources anywhere in resource3d/");
for v in vcounts.into_iter().take(top_n) {
let n = draw_count[&v];
let b = bufs.get(&v).map(|s| s.len()).unwrap_or(0);
let hit = by_vcount.get(&v).cloned().unwrap_or_default();
let label = if hit.is_empty() {
"— NONE".to_string()
} else {
let mut h = hit.clone();
h.sort();
let shown = h.len().min(8);
format!("{}{}", h[..shown].join(", "), if h.len() > shown { format!(", … ({} total)", h.len()) } else { String::new() })
};
println!("{v:6} {n:5} {b:4} {label}");
}
}

View File

@@ -0,0 +1,70 @@
//! Check the code-derived offset→field map against the live dump, then read out
//! the runtime value of every field the disc record leaves defaulted.
//! Run: verify_fieldmap <disc-root> <live-dump> <ID=0xVA>...
use std::collections::BTreeMap;
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
// Offsets read out of sub_82341A20's own key strings (stfs stores only).
// Offsets and names recovered from `sub_82341A20` itself: each key is built as
// `addi r4, r30, -N` (r30 = 0x82088f94, so the name is a string in the image),
// and the value lands in the first store into the object after the accessor
// call. 125 float-typed fields.
const MAP: &[(usize, &str)] = &[(48, "Size_X"), (52, "Size_Y"), (56, "Size_Z"), (64, "Color_R"), (68, "Color_G"), (72, "Color_B"), (80, "Size_Radius"), (84, "HP"), (88, "HQRatio"), (92, "ShieldRatio"), (96, "ThrusterRatio"), (116, "ResistanceToOptics"), (120, "ResistanceToShell"), (124, "ResistanceToExplosion"), (128, "ResistanceToPlayer"), (132, "ResistanceParalyze"), (156, "MinimumVelocity"), (160, "MaximumVelocity"), (164, "CruisingVelocity"), (168, "Acceleration"), (172, "Deceleration"), (176, "AV_PitchPlus_Max"), (180, "AV_PitchPlus_Min"), (184, "AA_PitchPlus_Max"), (188, "AA_PitchPlus_Min"), (192, "AV_PitchMinus_Max"), (196, "AV_PitchMinus_Min"), (200, "AA_PitchMinus_Max"), (204, "AA_PitchMinus_Min"), (208, "AV_Yaw_Max"), (212, "AV_Yaw_Min"), (216, "AA_Yaw_Max"), (220, "AA_Yaw_Min"), (224, "AV_Roll_Max"), (228, "AV_Roll_Min"), (232, "AA_Roll_Max"), (236, "AA_Roll_Min"), (248, "SideThrustVelocity_Max"), (252, "SideThrustAcceleration"), (256, "MaximumBank_Normal"), (260, "YawDragFactor"), (264, "PitchDragFactor"), (268, "RollDragFactor"), (272, "DragFactorThreshold"), (276, "ArterBurner_Vc"), (280, "ReverseThrust_Vc"), (284, "ArterBurner_Acc"), (288, "ReverseThrust_Acc"), (292, "AccPitchFactor"), (296, "DecPitchFactor"), (300, "AV_AxisMode_Max"), (304, "AV_AxisMode_Min"), (308, "AA_AxisMode_Max"), (312, "AA_AxisMode_Min"), (316, "PowerCutConsumeShield"), (320, "PowerCutDeceleration"), (324, "AB_ConsumeShield_Begin"), (328, "AB_ConsumeShield"), (332, "AB_AV_PitchPlus"), (336, "AB_AA_PitchPlus"), (340, "AB_AV_PitchMinus"), (344, "AB_AA_PitchMinus"), (348, "AB_AV_Yaw"), (352, "AB_AA_Yaw"), (356, "AB_AV_Roll"), (360, "AB_AA_Roll"), (368, "SideRoll_Time"), (372, "SideRoll_Length"), (380, "BarrelRoll_CountMinimum"), (384, "BarrelRoll_CountMaximum"), (388, "BarrelRoll_Time"), (392, "BarrelRoll_Radius"), (400, "TurnAttack_CutoffRatio"), (404, "TurnAttack_DoubleRatio"), (408, "CutoffTimeMin"), (412, "CutoffTimeMax"), (416, "TurnAttack_DoubleTimeMin"), (420, "TurnAttack_DoubleTimeMax"), (428, "Turn_AngularVelocity"), (432, "TurnAway_Time_Minimum"), (436, "TurnAway_Time_Maximum"), (444, "BoostAway_Time_Minimum"), (448, "BoostAway_Time_Maximum"), (456, "HoldPosition_LengthMin"), (460, "HoldPosition_LengthMax"), (464, "HoldPosition_MinimumTime"), (468, "HoldPosition_MaximumTime"), (472, "HoldPosition_SideRatio"), (476, "HoldPosition_BackRatio"), (480, "HoldPosition_CutoffRatio"), (484, "HoldPosition_CancelTime"), (492, "Slalom_CutoffRatio"), (496, "Slalom_TurnCount_Min"), (500, "Slalom_TurnCount_Max"), (508, "Through_CutoffRatio"), (512, "Through_AngleMinimum"), (516, "Through_AngleMaximum"), (520, "Through_Time1Max"), (524, "Through_Time1Min"), (528, "Through_Time2Max"), (532, "Through_Time2Min"), (536, "Through_LengthMinimum"), (540, "Through_LengthMaximum"), (548, "SolidCutoff_Ratio"), (552, "SolidCutoff_LengthMin"), (556, "SolidCutoff_LengthMax"), (560, "HomingResistAdjustment"), (564, "UsingChaffRatio"), (568, "MaxValue"), (572, "ChargeDelay"), (576, "ChargeDelay_Break"), (580, "ChargeSpeed"), (584, "Delay"), (588, "DelayAdjustment"), (624, "DestroyMotionTime"), (628, "DryMass"), (632, "GrossMass"), (664, "LowerHPThresholdRatio"), (668, "SELength"), (672, "RadarRange"), (676, "FCSRange"), (680, "FiringRange"), (692, "AttackVesselPoint"), (696, "AttackCraftPoint"), (700, "DefencePoint")];
fn main() {
let mut a = std::env::args().skip(1);
let disc = a.next().unwrap();
let dump = a.next().unwrap();
let pairs: Vec<(String, String)> =
a.filter_map(|s| s.split_once('=').map(|(i, v)| (i.into(), v.into()))).collect();
let mut live: BTreeMap<String, BTreeMap<usize, f32>> = BTreeMap::new();
let mut cur = String::new();
for line in std::fs::read_to_string(&dump).unwrap().lines() {
if let Some(r) = line.strip_prefix("=== ") { cur = r.trim().into(); continue; }
let f: Vec<&str> = line.split_whitespace().collect();
// dump columns: addr +off hex u32 f32 -- the float is f[4], not f[3]
if f.len() >= 5 && f[1].starts_with('+') {
if let (Ok(o), Ok(v)) = (usize::from_str_radix(&f[1][1..], 16), f[4].parse::<f32>()) {
live.entry(cur.clone()).or_default().insert(o, v);
}
}
}
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let (mut agree, mut disagree) = (0, 0);
let mut defaults: BTreeMap<String, Vec<(String, f32)>> = BTreeMap::new();
for e in pak.entries() {
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
let Some(id) = o.get_raw("ID") else { continue };
let Some((_, va)) = pairs.iter().find(|(i, _)| i == id) else { continue };
let Some(w) = live.get(va) else { continue };
for (off, key) in MAP {
let Some(got) = w.get(off) else { continue };
match o.get_f32(key) {
Some(want) => {
// Angle fields are stored in RADIANS at runtime and degrees on
// disc, so a match is either the raw value or its conversion.
let rad = want * std::f32::consts::PI / 180.0;
let ok = (want - got).abs() <= want.abs() * 1e-4
|| (rad - got).abs() <= rad.abs() * 1e-4;
if ok { agree += 1 } else {
disagree += 1;
println!(" MISMATCH {id} {key}: disc {want}, live +{off} = {got}");
}
}
None => defaults.entry((*key).into()).or_default().push((id.into(), *got)),
}
}
}
println!("\nmap check: {agree} fields agree with the disc, {disagree} disagree\n");
println!("runtime value where the disc record DEFAULTS the field:");
for (key, hits) in &defaults {
let vals: Vec<String> = hits.iter().map(|(_, v)| format!("{v}")).collect();
let uniq: std::collections::BTreeSet<&String> = vals.iter().collect();
println!(" {:<22} {:<28} ({} units)", key,
uniq.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "), hits.len());
}
}

View File

@@ -0,0 +1,19 @@
//! Why does a named resource never decode? Reports the furthest gate its best
//! candidate reached. Usage: why_missed <container.xpr> [name-substring]
use sylpheed_formats::mesh::{debug_best_rejection, xbg7_resource_names, Xbg7Model};
use std::collections::HashSet;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
let filter = a.get(2).cloned().unwrap_or_default();
let decoded: HashSet<String> = Xbg7Model::stage_models(&bytes).into_iter().map(|m| m.name).collect();
for n in xbg7_resource_names(&bytes) {
if decoded.contains(&n) || (!filter.is_empty() && !n.contains(&filter)) {
continue;
}
match debug_best_rejection(&bytes, &n) {
Some((rank, why)) => println!("{n:<28} rank {rank} {why}"),
None => println!("{n:<28} (no candidate reached any gate / skipped before anchoring)"),
}
}
}

View File

@@ -0,0 +1,14 @@
//! Why does the decoder refuse a grouped-pool resource at a given pool start?
//! Usage: why_rejected <container.xpr> <resource>@<vb0-hex>...
use sylpheed_formats::mesh::debug_grouped_report;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
for pair in &a[2..] {
let (name, off) = pair.split_once('@').expect("name@hex");
let vb0 = usize::from_str_radix(off.trim_start_matches("0x"), 16).expect("hex");
for line in debug_grouped_report(&bytes, name, vb0) {
println!("{line}");
}
}
}

View File

@@ -68,6 +68,10 @@ pub mod localization;
// Whole-ship assembly from XBG7 part families (capital ships as split parts). // Whole-ship assembly from XBG7 part families (capital ships as split parts).
pub mod ship; pub mod ship;
/// The runtime layout of a unit / vessel definition object, read out of the
/// title's loader and verified against a live mission (see the module docs).
pub mod unit_layout;
// Exact capital-ship placement from a runtime F10 ship-capture (ground truth). // Exact capital-ship placement from a runtime F10 ship-capture (ground truth).
pub mod ship_capture; pub mod ship_capture;

View File

@@ -1,9 +1,18 @@
//! `LSTA` — a sprite list: a header followed by N inline [`T8aD`](crate::t8ad) //! `LSTA` — a display list: a header followed by N inline elements concatenated
//! frames concatenated back-to-back. //! back-to-back, each either a [`T8aD`](crate::t8ad) sprite or a `PRMD`
//! primitive (a flat coloured quad — the same primitive the UI bundles use to
//! dim a scene).
//! //!
//! A `count` lives at `@0x04`, but a few entries disagree with the actual frame //! The `count` at `@0x04` is **exact, and counts both kinds**: across all 64
//! count, so we walk by the `T8aD` magic instead (robust) and decode each frame //! lists on the disc, `count == T8aD frames + PRMD primitives` with no
//! from its slice up to the next frame (or end). //! exceptions (measured 2026-08-11). An earlier note here said "a few entries
//! disagree with the actual frame count" — they do not; that comparison was
//! counting sprites against a total that includes primitives.
//!
//! [`parse`] walks by magic and returns the **sprites**, deliberately skipping
//! `PRMD` entries, so its result length is `count` only for lists that hold no
//! primitives. Six lists do (in `GP_DEBRIEFING_PILOTLOG`, `GP_MISSION_SELECT`),
//! each with exactly one.
use crate::t8ad::{self, T8adImage, T8AD_MAGIC}; use crate::t8ad::{self, T8adImage, T8AD_MAGIC};
@@ -15,8 +24,9 @@ pub fn is_lsta(bytes: &[u8]) -> bool {
bytes.len() >= 4 && bytes[0..4] == LSTA_MAGIC bytes.len() >= 4 && bytes[0..4] == LSTA_MAGIC
} }
/// Decode all inline T8aD frames. Frames that don't decode (unsupported T8aD /// Decode the inline T8aD sprites, skipping `PRMD` primitives. Returns `None`
/// variant) are skipped. Returns `None` only for non-LSTA input. /// only for non-LSTA input. Every one of the 1 281 sprite frames on the disc
/// decodes (measured 2026-08-11, after the T8aD rectangle-list fix).
pub fn parse(bytes: &[u8]) -> Option<Vec<T8adImage>> { pub fn parse(bytes: &[u8]) -> Option<Vec<T8adImage>> {
if !is_lsta(bytes) { if !is_lsta(bytes) {
return None; return None;
@@ -48,12 +58,21 @@ pub fn parse(bytes: &[u8]) -> Option<Vec<T8adImage>> {
mod tests { mod tests {
use super::*; use super::*;
/// A faithful one-rectangle T8aD frame: base header, a 1-entry offset table,
/// then the rectangle header (dst 0,0, size w×h) and its pixels. (Before
/// 2026-08-11 this fixture wrote no offset-table entry at all and the decoder
/// read "pixels" from inside the header — the test only ever checked the
/// dimensions, so it passed anyway.)
fn t8ad_frame(w: u32, h: u32) -> Vec<u8> { fn t8ad_frame(w: u32, h: u32) -> Vec<u8> {
let mut b = vec![0u8; 64]; let mut b = vec![0u8; 0x2c];
b[0..4].copy_from_slice(&T8AD_MAGIC); b[0..4].copy_from_slice(&T8AD_MAGIC);
b[0x14..0x18].copy_from_slice(&w.to_be_bytes()); b[0x14..0x18].copy_from_slice(&w.to_be_bytes());
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes()); b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes()); b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&0x30u32.to_be_bytes()); // offset table → rect at 0x30
for v in [0u32, 0, w, h] {
b.extend_from_slice(&v.to_be_bytes()); // dst X, dst Y, width, height
}
b.extend_from_slice(&vec![0x80u8; (w * h * 4) as usize]); b.extend_from_slice(&vec![0x80u8; (w * h * 4) as usize]);
b b
} }

File diff suppressed because it is too large Load Diff

View File

@@ -349,9 +349,21 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<Scen
let Some((_, gncat)) = CATS.iter().find(|(c, _)| *c == cat) else { let Some((_, gncat)) = CATS.iter().find(|(c, _)| *c == cat) else {
continue; continue;
}; };
if let Some(frame) = // An index-less part (`e105_brg`, against a `GN_Bridge_01` frame) used to
frames.iter().find(|f| f.resource.contains(gncat) && trailing_index(&f.resource) == idx) // compare `"01" == ""` and fall through, so the bridge was silently
{ // dropped from the assembly while the game draws it — caught by a runtime
// capture, which places `e105_brg` at the `GN_Bridge_01` frame exactly.
// With no index to match on, take the lowest-numbered frame of the
// category; an indexed part still matches its own index only.
let mut cands: Vec<&ScenePart> = frames
.iter()
.filter(|f| {
f.resource.contains(gncat)
&& (idx.is_empty() || trailing_index(&f.resource) == idx)
})
.collect();
cands.sort_by_key(|f| trailing_index(&f.resource).parse::<u32>().unwrap_or(u32::MAX));
if let Some(frame) = cands.first().copied() {
placed.push(ScenePart { resource: part.clone(), m: frame.m, t: frame.t, s: frame.s }); placed.push(ScenePart { resource: part.clone(), m: frame.m, t: frame.t, s: frame.s });
placed_res.insert(part.clone()); placed_res.insert(part.clone());
} }
@@ -603,18 +615,44 @@ mod tests {
} }
} }
} }
// ── Extras: the direction this test could not previously fail in. ──
// The loop above walks the CAPTURE's parts and looks each up in ours, so
// a static placement with no counterpart was invisible to it — which is
// how a resource decoded 100x too large (`e303_wep_01`, 2026-08-12) sat
// here unnoticed. Pin the set instead: the capture legitimately misses
// repeated instances of a shared resource (vbase dedup), so `e303_wep_01`
// is expected; anything else appearing only in the static assembly is a
// regression.
let captured: std::collections::BTreeSet<&str> =
cap.parts.iter().map(|p| p.part.as_str()).collect();
let extra: std::collections::BTreeSet<&str> = placed
.iter()
.map(|p| p.resource.as_str())
.filter(|r| !captured.contains(r))
.collect();
let allowed: std::collections::BTreeSet<&str> = ["e303_wep_01"].into_iter().collect();
assert_eq!(
extra, allowed,
"static placements with no counterpart in the runtime capture"
);
// Multi-instance coverage the capture couldn't see (vbase dedup). // Multi-instance coverage the capture couldn't see (vbase dedup).
let count = |res: &str| placed.iter().filter(|p| p.resource == res).count(); let count = |res: &str| placed.iter().filter(|p| p.resource == res).count();
assert_eq!(count("e106_eng_01"), 2, "both engine nacelles placed"); assert_eq!(count("e106_eng_01"), 2, "both engine nacelles placed");
assert_eq!(count("e303_wep_01"), 2, "both shared turrets placed"); assert_eq!(count("e303_wep_01"), 2, "both shared turrets placed");
// The mirrored starboard hull reflects (det < 0), the port one doesn't. // NEITHER hull reflects: the twins' geometry is mirrored on the disc,
// so both placements are proper rotations. This flipped on 2026-08-12 —
// while both twins decoded to one buffer, `apply_twin_mirrors` had to
// synthesise the reflection here; a runtime capture showed the container
// holds both halves, and distinct anchor assignment now hands each twin
// its own (see docs/re/structures/xbg7-mesh.md).
let det = |m: &[[f32; 3]; 3]| { let det = |m: &[[f32; 3]; 3]| {
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]) + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
}; };
let one = |res: &str| placed.iter().find(|p| p.resource == res).unwrap(); let one = |res: &str| placed.iter().find(|p| p.resource == res).unwrap();
assert!(det(&one("e106_bdy_02").m) < 0.0, "starboard hull mirrored"); assert!(det(&one("e106_bdy_02").m) > 0.0, "starboard hull plain (mirror is in the data)");
assert!(det(&one("e106_bdy_01").m) > 0.0, "port hull plain"); assert!(det(&one("e106_bdy_01").m) > 0.0, "port hull plain");
} }

View File

@@ -49,6 +49,33 @@ pub struct CapturedDraw {
/// First few LOCAL vertex positions dumped with the draw (buffer order). /// First few LOCAL vertex positions dumped with the draw (buffer order).
/// Used to disambiguate same-vcount twins (mirrored port/starboard parts). /// Used to disambiguate same-vcount twins (mirrored port/starboard parts).
pub pos: Vec<[f32; 3]>, pub pos: Vec<[f32; 3]>,
/// The draw's INDEX buffer, when the capture recorded one (`ib base=…`,
/// added 2026-08-13): guest base address, index count, and the min/max index
/// value the emulator read out of guest memory. `None` for older logs and
/// for auto-index draws. This is ground truth for two things the offline
/// decoder can only assume — where a block's index buffer lives relative to
/// its vertex buffer, and how much of the vertex pool a draw really covers.
pub ib: Option<CapturedIndexBuffer>,
}
/// The index buffer a captured draw used. See [`CapturedDraw::ib`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CapturedIndexBuffer {
/// Guest base address of the index data.
pub ibase: u32,
/// Number of indices the draw issued (== `VGT_DRAW_INITIATOR.num_indices`).
pub icount: u32,
/// Lowest index value in the buffer.
pub imin: u32,
/// Highest index value in the buffer — with `vcount` this says whether the
/// draw covers its whole vertex pool or only a sub-range.
pub imax: u32,
/// The first indices, verbatim (the capture prints up to 24). Byte-level
/// ground truth for the offline index decode: a matched block's decoded
/// index prefix must equal this run.
pub head: [u32; 24],
/// How many of `head` the capture actually carried.
pub head_len: u8,
} }
/// A ship part to match against the capture. `part` is the **base** part name /// A ship part to match against the capture. `part` is the **base** part name
@@ -100,13 +127,16 @@ pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
let mut vcount = 0u32; let mut vcount = 0u32;
let mut pos: Vec<[f32; 3]> = Vec::new(); let mut pos: Vec<[f32; 3]> = Vec::new();
let mut consts: Vec<(usize, [f64; 4])> = Vec::new(); let mut consts: Vec<(usize, [f64; 4])> = Vec::new();
let mut ib: Option<CapturedIndexBuffer> = None;
let flush = |vbase: u32, let flush = |vbase: u32,
vcount: u32, vcount: u32,
pos: &mut Vec<[f32; 3]>, pos: &mut Vec<[f32; 3]>,
ib: &mut Option<CapturedIndexBuffer>,
consts: &[(usize, [f64; 4])], consts: &[(usize, [f64; 4])],
out: &mut Vec<CapturedDraw>| { out: &mut Vec<CapturedDraw>| {
let pos = std::mem::take(pos); let pos = std::mem::take(pos);
let ib = ib.take();
if vbase == 0 { if vbase == 0 {
return; return;
} }
@@ -115,18 +145,47 @@ pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
return; // no WorldView for this draw — skip it return; // no WorldView for this draw — skip it
}; };
if let Some((r, t)) = normalize_wvp([c0, c1, c2]) { if let Some((r, t)) = normalize_wvp([c0, c1, c2]) {
out.push(CapturedDraw { vbase, vcount, r, t, pos }); out.push(CapturedDraw { vbase, vcount, r, t, pos, ib });
} }
}; };
for line in text.lines() { for line in text.lines() {
let l = line.trim(); let l = line.trim();
if let Some(rest) = l.strip_prefix("DRAW ") { if let Some(rest) = l.strip_prefix("DRAW ") {
flush(vbase, vcount, &mut pos, &consts, &mut out); flush(vbase, vcount, &mut pos, &mut ib, &consts, &mut out);
consts.clear(); consts.clear();
let f = |k: &str| rest.split_whitespace().find_map(|t| t.strip_prefix(k)); let f = |k: &str| rest.split_whitespace().find_map(|t| t.strip_prefix(k));
vbase = f("vbase=0x").and_then(|s| u32::from_str_radix(s, 16).ok()).unwrap_or(0); vbase = f("vbase=0x").and_then(|s| u32::from_str_radix(s, 16).ok()).unwrap_or(0);
vcount = f("vcount=").and_then(|s| s.parse().ok()).unwrap_or(0); vcount = f("vcount=").and_then(|s| s.parse().ok()).unwrap_or(0);
} else if let Some(rest) = l.strip_prefix("ib base=0x") {
// `ib base=0x… count=N fmt=u16 endian=E len=L delta_vb=D min=a max=b idx: …`
let f = |k: &str| rest.split_whitespace().find_map(|t| t.strip_prefix(k));
let base = rest
.split_whitespace()
.next()
.and_then(|s| u32::from_str_radix(s, 16).ok());
if let (Some(ibase), Some(icount)) = (base, f("count=").and_then(|s| s.parse().ok())) {
let mut head = [0u32; 24];
let mut head_len = 0u8;
if let Some((_, list)) = l.split_once("idx:") {
for tok in list.split_whitespace() {
let Ok(v) = tok.parse::<u32>() else { break };
if head_len as usize >= head.len() {
break;
}
head[head_len as usize] = v;
head_len += 1;
}
}
ib = Some(CapturedIndexBuffer {
ibase,
icount,
imin: f("min=").and_then(|s| s.parse().ok()).unwrap_or(0),
imax: f("max=").and_then(|s| s.parse().ok()).unwrap_or(0),
head,
head_len,
});
}
} else if l.starts_with("pos:") || l.starts_with("positions:") { } else if l.starts_with("pos:") || l.starts_with("positions:") {
pos = parse_pos_line(l, 8); pos = parse_pos_line(l, 8);
} else if l.starts_with("vsconst") { } else if l.starts_with("vsconst") {
@@ -147,7 +206,7 @@ pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
} }
} }
} }
flush(vbase, vcount, &mut pos, &consts, &mut out); flush(vbase, vcount, &mut pos, &mut ib, &consts, &mut out);
out out
} }
@@ -183,6 +242,41 @@ pub const SHIP_VS_HASH: &str = "0xC7F781F4C1D58054";
/// with `vs=`[`SHIP_VS_HASH`] are kept (the ship shader), so HUD/skybox draws are /// with `vs=`[`SHIP_VS_HASH`] are kept (the ship shader), so HUD/skybox draws are
/// ignored. (The player fighter shares ONE buffer across its fin draws and so /// ignored. (The player fighter shares ONE buffer across its fin draws and so
/// collapses to a single entry here — fine, capital ships are the target.) /// collapses to a single entry here — fine, capital ships are the target.)
/// Split a capture into blocks that are guaranteed to share one camera.
///
/// **Why this is not optional.** One F10 press dumps a flat list of draws with
/// no frame delimiter, and it spans ~14 frames (the same vertex buffer recurs
/// that many times). The placement math is `WV_ref⁻¹ · WV_p`, which cancels the
/// camera **only when both draws come from the same frame** — mix frames and
/// the residual is the camera's motion between them. With a static ship and a
/// static camera that error is invisible, which is how the single validated
/// `e106` capture passed; closing on a cruiser at ~760 u/s it is hundreds of
/// units, and two frames of the same ship then disagree about where its parts
/// are (measured 2026-08-10: `f105_bdy_02` at `[488, 736, -620]` vs
/// `[0, 0, -1090]`).
///
/// The split rule is the recurrence itself: a vertex buffer that appears again
/// starts a new block. Splitting too eagerly is harmless (a block is still one
/// camera, just with fewer parts in it) and it separates two instances of the
/// same class as a bonus; failing to split is what corrupts the result.
pub fn segment_frames(draws: &[CapturedDraw]) -> Vec<Vec<CapturedDraw>> {
let mut out: Vec<Vec<CapturedDraw>> = Vec::new();
let mut cur: Vec<CapturedDraw> = Vec::new();
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
for d in draws {
if !seen.insert(d.vbase) {
out.push(std::mem::take(&mut cur));
seen.clear();
seen.insert(d.vbase);
}
cur.push(d.clone());
}
if !cur.is_empty() {
out.push(cur);
}
out
}
pub fn parse_drawlog(text: &str) -> Vec<CapturedDraw> { pub fn parse_drawlog(text: &str) -> Vec<CapturedDraw> {
let mut out = Vec::new(); let mut out = Vec::new();
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new(); let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
@@ -207,7 +301,9 @@ pub fn parse_drawlog(text: &str) -> Vec<CapturedDraw> {
let get = |i: usize| consts.iter().find(|(k, _)| *k == i).map(|(_, v)| *v); let get = |i: usize| consts.iter().find(|(k, _)| *k == i).map(|(_, v)| *v);
let (Some(c0), Some(c1), Some(c2)) = (get(0), get(1), get(2)) else { return }; let (Some(c0), Some(c1), Some(c2)) = (get(0), get(1), get(2)) else { return };
if let Some((r, t)) = normalize_wvp([c0, c1, c2]) { if let Some((r, t)) = normalize_wvp([c0, c1, c2]) {
out.push(CapturedDraw { vbase: base, vcount: size / stride, r, t, pos }); // The draw-logger format carries an index base too, but it de-dups
// by vertex declaration, so it never lines up per part — left None.
out.push(CapturedDraw { vbase: base, vcount: size / stride, r, t, pos, ib: None });
} }
}; };
@@ -682,10 +778,17 @@ mod tests {
assert_eq!(e106.reference, "e106_bdy_04"); assert_eq!(e106.reference, "e106_bdy_04");
assert_eq!(e106.parts.len(), 8, "all 8 e106 parts placed"); assert_eq!(e106.parts.len(), 8, "all 8 e106 parts placed");
let get = |p: &str| e106.parts.iter().find(|x| x.part == p).unwrap(); let get = |p: &str| e106.parts.iter().find(|x| x.part == p).unwrap();
// Port/starboard hull pair: X = ∓264, the starboard copy mirrored. // Port/starboard hull pair: X = ∓264, **both plain**. The mirror is
// baked into the disc data, not into the placement: a runtime capture
// shows the container carrying two 119-vertex buffers whose contents are
// exact X-reflections, each drawn from its own address (see
// docs/re/structures/xbg7-mesh.md). Until 2026-08-12 both twins decoded
// to ONE buffer and this row carried diag(-1,1,1) to compensate; with
// distinct anchor assignment they decode to their own, and re-emitting
// from the capture produces identity here.
assert!((get("e106_bdy_01").t[0] + 264.0).abs() < 0.1); assert!((get("e106_bdy_01").t[0] + 264.0).abs() < 0.1);
assert!((get("e106_bdy_02").t[0] - 264.0).abs() < 0.1); assert!((get("e106_bdy_02").t[0] - 264.0).abs() < 0.1);
assert_eq!(get("e106_bdy_02").m[0][0], -1.0); assert_eq!(get("e106_bdy_02").m[0][0], 1.0);
assert_eq!(get("e106_bdy_01").m[0][0], 1.0); assert_eq!(get("e106_bdy_01").m[0][0], 1.0);
// Bridge: centreline, above and aft of the hull reference. // Bridge: centreline, above and aft of the hull reference.
let brg = get("e106_brg_01"); let brg = get("e106_brg_01");

View File

@@ -1,20 +1,28 @@
//! `T8aD` — the game's 2D UI/HUD texture format. //! `T8aD` — the game's 2D UI/HUD texture format.
//! //!
//! A 32bpp **A8R8G8B8** (Xbox byte order) surface stored as **256×256 raster //! A 32bpp **A8R8G8B8** (Xbox byte order) surface stored as a list of
//! tiles in row-major order** — each tile prefixed by a 16-byte tile header, edge //! **arbitrary sub-rectangles**, each with its own destination origin and size.
//! tiles clipped to the image bounds. Fully reversed 2026-07-17 from the file //! Reversed 2026-07-17 (verified against the running game's title screen) and
//! header and verified against the running game (title screen). //! **corrected 2026-08-11**, when the per-tile header turned out to carry the
//! rectangle's placement rather than being opaque flags.
//! //!
//! ```text //! ```text
//! 0x00 4 Magic "T8aD" //! 0x00 4 Magic "T8aD"
//! 0x14 4 width (BE u32) //! 0x14 4 width (BE u32) the full surface
//! 0x18 4 height (BE u32) //! 0x18 4 height (BE u32)
//! 0x1c 4 tile count (BE u32) = ceil(w/256) * ceil(h/256) //! 0x1c 4 rectangle count (BE u32) — NOT ceil(w/256)*ceil(h/256)
//! 0x2c tiles*4 offset table: absolute byte offset of each row-major tile //! 0x2c count*4 offset table: absolute byte offset of each rectangle
//! <off> 16 per-tile header (flags + tile w/h), then: //! <off> 16 rectangle header, four BE u32: dst X, dst Y, width, height
//! <off+16> tile_w * tile_h * 4 bytes of A8R8G8B8 pixels, row-major //! <off+16> width * height * 4 bytes of A8R8G8B8 pixels, row-major
//! ``` //! ```
//! //!
//! Most surfaces happen to be stored as full-width 256-tall bands, which is why
//! treating the file as a 256×256 grid decoded 96 % of the disc correctly. It is
//! not the model, though: a dialogue strip declares 524×63 and stores **one**
//! 173×25 rectangle at (175,20), and `pdmes010` stores two — (59,6,256,54) and
//! (315,6,149,54), the second beginning exactly `16 + 256*54*4` bytes after the
//! first. Anything the rectangles do not cover stays transparent.
//!
//! Surfaces ≤256px wide are a single tile column, so the first tile's pixels sit //! Surfaces ≤256px wide are a single tile column, so the first tile's pixels sit
//! at `44 + tiles*4 + 16 = 64` — which is why the old "type→header size 64/84/…" //! at `44 + tiles*4 + 16 = 64` — which is why the old "type→header size 64/84/…"
//! rule (header = 44 + tiles*20) happened to decode small textures correctly: for //! rule (header = 44 + tiles*20) happened to decode small textures correctly: for
@@ -43,18 +51,11 @@ fn be32(b: &[u8], off: usize) -> u32 {
} }
/// Side of the square storage tile, in texels, and the per-tile header size. /// Side of the square storage tile, in texels, and the per-tile header size.
const TILE: usize = 256; /// Bytes of per-rectangle header before its pixels: dst X, dst Y, w, h.
const TILE_HDR: usize = 16; const RECT_HDR: usize = 16;
/// Decode a T8aD surface from a slice whose first bytes ARE the magic. Returns /// Decode a T8aD surface from a slice whose first bytes ARE the magic. Returns
/// `None` for non-T8aD input or a variant we can't decode as RGBA (never guesses). /// `None` if any rectangle fails to fit the surface or the file — never guesses.
///
/// Layout (reversed from the header + verified against the running game):
/// a 44-byte base header, then a `tiles`-entry big-endian u32 **offset table** at
/// `0x2c`, where `tiles` = the field at `0x1c` = `ceil(w/256) * ceil(h/256)`.
/// Each entry is the absolute byte offset of a **row-major** 256×256 tile; every
/// tile is a 16-byte tile header followed by `tile_w*tile_h*4` A8R8G8B8 pixels,
/// edge tiles clipped to the image bounds.
pub fn parse(bytes: &[u8]) -> Option<T8adImage> { pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
if !is_t8ad(bytes) || bytes.len() < 0x40 { if !is_t8ad(bytes) || bytes.len() < 0x40 {
return None; return None;
@@ -64,33 +65,38 @@ pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
if !(1..=4096).contains(&width) || !(1..=4096).contains(&height) { if !(1..=4096).contains(&width) || !(1..=4096).contains(&height) {
return None; return None;
} }
let tiles = be32(bytes, 0x1c) as usize; let rects = be32(bytes, 0x1c) as usize;
let cols = width.div_ceil(TILE); if rects == 0 || rects > 4096 {
let rows = height.div_ceil(TILE);
// The field at 0x1c must be the tile count; otherwise it's a variant we don't
// decode (e.g. DXT / palettized) — defer rather than misdecode.
if tiles == 0 || tiles != cols * rows {
return None; return None;
} }
const TABLE: usize = 0x2c; const TABLE: usize = 0x2c;
if bytes.len() < TABLE + tiles * 4 { if bytes.len() < TABLE + rects * 4 {
return None; return None;
} }
// Anything no rectangle covers stays transparent.
let mut rgba = vec![0u8; width * height * 4]; let mut rgba = vec![0u8; width * height * 4];
for ty in 0..rows { for r in 0..rects {
for tx in 0..cols { let at = be32(bytes, TABLE + r * 4) as usize;
let tile = ty * cols + tx; if at + RECT_HDR > bytes.len() {
let pixels = be32(bytes, TABLE + tile * 4) as usize + TILE_HDR; return None;
let tw = TILE.min(width - tx * TILE);
let th = TILE.min(height - ty * TILE);
if pixels + tw * th * 4 > bytes.len() {
return None; // truncated / not the layout we expect
} }
for row in 0..th { let dx = be32(bytes, at) as usize;
let mut s = pixels + row * tw * 4; let dy = be32(bytes, at + 4) as usize;
let mut d = ((ty * TILE + row) * width + tx * TILE) * 4; let rw = be32(bytes, at + 8) as usize;
for _ in 0..tw { let rh = be32(bytes, at + 12) as usize;
// Never guess: a rectangle must fit the surface and its pixels the file.
if rw == 0 || rh == 0 || dx + rw > width || dy + rh > height {
return None;
}
let pixels = at + RECT_HDR;
if pixels + rw * rh * 4 > bytes.len() {
return None;
}
for row in 0..rh {
let mut s = pixels + row * rw * 4;
let mut d = ((dy + row) * width + dx) * 4;
for _ in 0..rw {
// A8R8G8B8 → RGBA8. // A8R8G8B8 → RGBA8.
rgba[d] = bytes[s + 1]; rgba[d] = bytes[s + 1];
rgba[d + 1] = bytes[s + 2]; rgba[d + 1] = bytes[s + 2];
@@ -101,7 +107,6 @@ pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
} }
} }
} }
}
Some(T8adImage { Some(T8adImage {
width: width as u32, width: width as u32,
height: height as u32, height: height as u32,
@@ -123,7 +128,11 @@ mod tests {
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes()); b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes()); // 1 tile b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes()); // 1 tile
b.extend_from_slice(&0x30u32.to_be_bytes()); // offset table: tile 0 @ 0x30 b.extend_from_slice(&0x30u32.to_be_bytes()); // offset table: tile 0 @ 0x30
b.extend_from_slice(&[0u8; 16]); // 16-byte tile header → pixels at 0x40 // rectangle header: dst (0,0), size w×h
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&w.to_be_bytes());
b.extend_from_slice(&h.to_be_bytes());
for i in 0..(w * h) { for i in 0..(w * h) {
b.extend_from_slice(&[(i & 0xff) as u8, 0x24, 0x63, 0xB2]); // A, R, G, B b.extend_from_slice(&[(i & 0xff) as u8, 0x24, 0x63, 0xB2]); // A, R, G, B
} }
@@ -143,8 +152,8 @@ mod tests {
} }
#[test] #[test]
fn assembles_row_major_tiles_via_offset_table() { fn assembles_rectangles_via_offset_table() {
// 300×1 → 2 tiles: (0,0)=256×1 red, (1,0)=44×1 blue, each +16-byte header. // 300×1 → 2 rectangles: (0,0) 256×1 red, then (256,0) 44×1 blue.
let (w, h): (u32, u32) = (300, 1); let (w, h): (u32, u32) = (300, 1);
let mut b = vec![0u8; 0x2c]; let mut b = vec![0u8; 0x2c];
b[0..4].copy_from_slice(&T8AD_MAGIC); b[0..4].copy_from_slice(&T8AD_MAGIC);
@@ -155,9 +164,9 @@ mod tests {
let off1 = off0 + 16 + 256 * 4; // tile-0 header + its 256 pixels let off1 = off0 + 16 + 256 * 4; // tile-0 header + its 256 pixels
b.extend_from_slice(&(off0 as u32).to_be_bytes()); b.extend_from_slice(&(off0 as u32).to_be_bytes());
b.extend_from_slice(&(off1 as u32).to_be_bytes()); b.extend_from_slice(&(off1 as u32).to_be_bytes());
b.extend_from_slice(&[0u8; 16]); for v in [0u32, 0, 256, 1] { b.extend_from_slice(&v.to_be_bytes()) } // dst(0,0) 256×1
b.extend_from_slice(&[0xFF, 0xFF, 0, 0].repeat(256)); // A,R,G,B red b.extend_from_slice(&[0xFF, 0xFF, 0, 0].repeat(256)); // A,R,G,B red
b.extend_from_slice(&[0u8; 16]); for v in [256u32, 0, 44, 1] { b.extend_from_slice(&v.to_be_bytes()) } // dst(256,0) 44×1
b.extend_from_slice(&[0xFF, 0, 0, 0xFF].repeat(44)); // A,R,G,B blue b.extend_from_slice(&[0xFF, 0, 0, 0xFF].repeat(44)); // A,R,G,B blue
let img = parse(&b).expect("decodes"); let img = parse(&b).expect("decodes");
assert_eq!((img.width, img.height), (300, 1)); assert_eq!((img.width, img.height), (300, 1));
@@ -166,10 +175,11 @@ mod tests {
} }
#[test] #[test]
fn rejects_wrong_tilecount_and_short() { fn rejects_out_of_range_rect_and_short() {
// tile-count field that isn't ceil(w/256)*ceil(h/256) → None // a rectangle that does not fit the declared surface → None, never guess
let mut b = synth(2, 2); let mut b = synth(2, 2);
b[0x1c..0x20].copy_from_slice(&7u32.to_be_bytes()); let at = 0x2c + 4;
b[at + 8..at + 12].copy_from_slice(&99u32.to_be_bytes()); // width 99 > 2
assert!(parse(&b).is_none()); assert!(parse(&b).is_none());
// truncated pixel data → None // truncated pixel data → None
let b = synth(64, 64); let b = synth(64, 64);

View File

@@ -0,0 +1,105 @@
//! The runtime layout of a unit / vessel definition object.
//!
//! The game parses an [`crate::idxd`] record into a fixed 880-byte object whose
//! field offsets are **not** guessable from the disc data: the record is a
//! reflective key/value pool, and the loader assigns each key to a member by
//! name. This table is that assignment, read out of the loader itself
//! (`sub_82341A20` — every key is built as `addi r4, r30, -N`, so the field name
//! for each store is a string in the executable image), and verified against
//! objects dumped from a running mission: **406 values agree with the disc
//! records, 0 disagree**, over 11 objects covering both schemas.
//!
//! Why a reimplementation wants it:
//!
//! - it names the field behind every word of a live definition object, so a
//! memory snapshot can be read directly;
//! - it says which fields a record leaves **defaulted**, and what the loader
//! leaves there — the float accessor returns `0.0` on a pool miss;
//! - it carries two conventions that are invisible on disc: **angles are degrees
//! in the data and radians in the object**, and **`Size_Y` takes `Size_X`**
//! when omitted.
//!
//! See `docs/re/live-unit-definitions.md` for the derivation and the measured
//! default values.
/// How a field is stored in the definition object.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
/// IEEE-754 single, big-endian.
F32,
/// Pointer to a string.
Str,
/// 32-bit word (bool / enum / count / id).
Word,
}
/// One field of the definition object.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Field {
/// Byte offset from the start of the object.
pub offset: usize,
/// Field name, exactly as the disc record spells it.
pub name: &'static str,
/// Storage kind.
pub kind: Kind,
}
const TABLE: &str = include_str!("../data/unit_definition_layout.txt");
/// Every mapped field, in offset order.
pub fn fields() -> Vec<Field> {
TABLE
.lines()
.filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty())
.filter_map(|l| {
let mut it = l.split_whitespace();
let offset = it.next()?.parse().ok()?;
let kind = match it.next()? {
"f32" => Kind::F32,
"str" => Kind::Str,
_ => Kind::Word,
};
// `name` is a &'static str because TABLE is 'static.
let name = it.next()?;
let name: &'static str = TABLE.get(
TABLE.find(name).map(|s| s..s + name.len())?,
)?;
Some(Field { offset, name, kind })
})
.collect()
}
/// The field at `offset`, if one is mapped there.
pub fn field_at(offset: usize) -> Option<Field> {
fields().into_iter().find(|f| f.offset == offset)
}
/// The offset of `name`, if it is mapped.
pub fn offset_of(name: &str) -> Option<usize> {
fields().into_iter().find(|f| f.name == name).map(|f| f.offset)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_parses_and_is_ordered() {
let f = fields();
assert!(f.len() > 150, "expected the full map, got {}", f.len());
assert!(f.windows(2).all(|w| w[0].offset < w[1].offset), "offsets must be strictly increasing");
}
#[test]
fn known_fields_sit_where_the_loader_puts_them() {
// Spot-checks from the verified map; these four also anchor the
// identification of a live object (see docs/re/live-unit-definitions.md).
assert_eq!(offset_of("Size_X"), Some(48));
assert_eq!(offset_of("Size_Y"), Some(52));
assert_eq!(offset_of("Size_Z"), Some(56));
assert_eq!(offset_of("HP"), Some(84));
assert_eq!(offset_of("HQRatio"), Some(88));
assert_eq!(field_at(96).map(|f| f.name), Some("ThrusterRatio"));
assert_eq!(field_at(48).map(|f| f.kind), Some(Kind::F32));
}
}

View File

@@ -0,0 +1,144 @@
//! Cross-container consistency for XBG7 geometry.
//!
//! A geometry resource shared by several stage containers must decode to the
//! same bounds in each. This needs no ground truth, and on 2026-08-11 it found
//! **125 of 681** shared resources decoding to different bounds while reporting
//! identical vertex and triangle counts — the anchor scan locating a different
//! buffer of the same size (see `docs/re/structures/xbg7-mesh.md`).
//!
//! The test is `#[ignore]`d because the decoder does not satisfy it yet. It is
//! written as the *target* state so that fixing the anchor scan makes it pass,
//! rather than as a snapshot of the bug.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use sylpheed_formats::mesh::Xbg7Model;
fn disc_root() -> Option<PathBuf> {
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
let p = PathBuf::from(p);
if p.join("dat").is_dir() {
return Some(p);
}
}
let default = Path::new(
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)",
);
if default.join("dat").is_dir() {
return Some(default.to_path_buf());
}
None
}
/// Rounded (w, h, d) of a model's own geometry.
fn span(m: &Xbg7Model) -> Option<[i64; 3]> {
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for s in &m.meshes {
for q in &s.positions {
for k in 0..3 {
lo[k] = lo[k].min(q[k]);
hi[k] = hi[k].max(q[k]);
}
}
}
if lo[0] == f32::MAX {
return None;
}
Some([
(hi[0] - lo[0]).round() as i64,
(hi[1] - lo[1]).round() as i64,
(hi[2] - lo[2]).round() as i64,
])
}
#[test]
#[ignore = "known-failing: 62 of 714 shared resources decode inconsistently (was 125 of 681; distinct anchor assignment + the 0.42 connectivity cap fixed the rest). Note this metric is the WEAKER witness — a systematic mis-anchor is consistent — see docs/re/structures/xbg7-mesh.md"]
fn shared_resources_decode_identically_in_every_container() {
let Some(root) = disc_root() else {
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
return;
};
let dir = root.join("hidden/resource3d");
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
.expect("resource3d/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
// name -> (verts, tris) -> set of spans seen
let mut seen: BTreeMap<String, Vec<([i64; 3], usize, usize, String)>> = BTreeMap::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
let Some(sp) = span(&m) else { continue };
let v: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
let t: usize = m.meshes.iter().map(|s| s.indices.len() / 3).sum();
seen.entry(m.name.clone()).or_default().push((sp, v, t, where_.clone()));
}
}
let mut bad: Vec<String> = Vec::new();
for (name, list) in &seen {
if list.len() < 2 {
continue;
}
// Only compare decodes that agree on how much geometry they found;
// a differing vertex/triangle count is a different question.
if !list.iter().all(|e| e.1 == list[0].1 && e.2 == list[0].2) {
continue;
}
let spans: std::collections::BTreeSet<[i64; 3]> = list.iter().map(|e| e.0).collect();
if spans.len() > 1 && bad.len() < 10 {
bad.push(format!("{name}: {spans:?}"));
}
}
assert!(
bad.is_empty(),
"{} shared resources decode to different bounds; first: {bad:?}",
bad.len()
);
}
/// Port/starboard twins must not decode to the *same* buffer.
///
/// A runtime capture showed the container stores both halves of the `e106` hull
/// as separate X-reflected buffers, so a `…_01`/`…_02` pair of equal vertex
/// count should come out mirrored (or related by another axis / vertex order) —
/// never identical, which is the collapse distinct assignment fixes. Since that
/// fix reached grouped pools too, this holds for **every** twin pair on the disc
/// — including `n206`, which was the last exception.
#[test]
fn twin_pairs_do_not_share_a_buffer() {
let Some(root) = disc_root() else {
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
return;
};
let mut files: Vec<PathBuf> = std::fs::read_dir(root.join("hidden/resource3d"))
.expect("resource3d/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let mut collapsed: Vec<String> = Vec::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let models = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false);
let by_name: BTreeMap<&str, &Xbg7Model> =
models.iter().map(|m| (m.name.as_str(), m)).collect();
for m in &models {
let Some(stem) = m.name.strip_suffix("_01") else { continue };
let Some(t) = by_name.get(format!("{stem}_02").as_str()) else { continue };
let (a, b) = (m.meshes[0].vbuf_offset, t.meshes[0].vbuf_offset);
if a.is_some() && a == b {
collapsed.push(format!("{}/{stem}_02 in {}", m.name, f.file_name().unwrap().to_string_lossy()));
}
}
}
assert!(collapsed.is_empty(), "twin pairs sharing one buffer: {collapsed:?}");
}

View File

@@ -17,7 +17,7 @@ fn res3d_dir() -> Option<PathBuf> {
} }
} }
let default = let default =
PathBuf::from("/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d"); PathBuf::from("/home/fabi/RE - Project Sylpheed/sylph_extract/hidden/resource3d");
default.is_dir().then_some(default) default.is_dir().then_some(default)
} }
@@ -321,7 +321,7 @@ fn hero_ship_grouped_pool_decodes() {
fn stage_models_decode() { fn stage_models_decode() {
use sylpheed_formats::mesh::Xbg7Model; use sylpheed_formats::mesh::Xbg7Model;
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| { let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string() "/home/fabi/RE - Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
}); });
let path = format!("{dir}/Stage_S10.xpr"); let path = format!("{dir}/Stage_S10.xpr");
let bytes = std::fs::read(&path).expect("read Stage_S10"); let bytes = std::fs::read(&path).expect("read Stage_S10");
@@ -359,7 +359,7 @@ fn stage_models_sweep() {
use sylpheed_formats::mesh::Xbg7Model; use sylpheed_formats::mesh::Xbg7Model;
use std::time::Instant; use std::time::Instant;
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| { let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string() "/home/fabi/RE - Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
}); });
let mut names: Vec<_> = std::fs::read_dir(&dir) let mut names: Vec<_> = std::fs::read_dir(&dir)
.unwrap() .unwrap()
@@ -386,7 +386,7 @@ fn stage_models_sweep() {
fn stage_models_quality_audit() { fn stage_models_quality_audit() {
use sylpheed_formats::mesh::Xbg7Model; use sylpheed_formats::mesh::Xbg7Model;
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| { let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string() "/home/fabi/RE - Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
}); });
let bytes = std::fs::read(format!("{dir}/Stage_S07.xpr")).unwrap(); let bytes = std::fs::read(format!("{dir}/Stage_S07.xpr")).unwrap();
let models = Xbg7Model::stage_models(&bytes); let models = Xbg7Model::stage_models(&bytes);
@@ -424,3 +424,59 @@ fn stage_models_quality_audit() {
assert!(huge < models.len() / 20, "few huge (skybox-plane) models"); assert!(huge < models.len() / 20, "few huge (skybox-plane) models");
assert!(worst_deg < 0.35, "no model should be mostly-degenerate"); assert!(worst_deg < 0.35, "no model should be mostly-degenerate");
} }
/// A correctly located index run has **no degenerate triangles**. That is the
/// signature the 2026-08-13 pad-scoring fix keys on: an index list read one
/// element late still passes every count-based gate but wires arbitrary
/// vertices, which produces triangles with a repeated index. Before the fix
/// **579** decoded runs on the disc carried such triangles (and a runtime
/// capture confirmed 17 of 93 index batches disagreed with the GPU); after it and
/// the two follow-ups (pad scoring in the grouped path, and preferring a
/// degenerate-free candidate over an earlier dirty one), exactly **one** does.
/// Locking the
/// number in, because the defect is invisible to coverage and to the anchor
/// oracle: every count stays correct while the geometry is mis-wired.
#[test]
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
fn decoded_index_runs_have_almost_no_degenerate_triangles() {
let Some(dir) = res3d_dir() else {
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
return;
};
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
.expect("resource3d/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let mut offenders: Vec<(String, String, usize)> = Vec::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
for m in Xbg7Model::stage_models(&bytes) {
for sm in &m.meshes {
let d = sm
.indices
.chunks_exact(3)
.filter(|t| t[0] == t[1] || t[1] == t[2] || t[0] == t[2])
.count();
if d > 0 {
offenders.push((m.name.clone(), where_.clone(), d));
}
}
}
}
// The one at default settings: `_rou_f402_dead` in `Stage_S09`, whose
// degenerate-free block is claimed by `e_rou_f003_Near` — both 24-vertex
// bounding boxes, the identity class that needs descriptor-level data. (With
// `XBG7_SUBMESH_DECLS=1` four newly decoded `ptc_pack` `.dat` composites join
// it; that knob is off by default.) Anything else here is a regression.
assert!(
offenders.len() <= 1,
"{} decoded index runs contain degenerate triangles (expected ≤ 1): {:?}",
offenders.len(),
&offenders[..offenders.len().min(20)]
);
}

View File

@@ -19,7 +19,7 @@ fn iso_path() -> Option<PathBuf> {
} }
} }
let default = PathBuf::from( let default = PathBuf::from(
"/home/fabi/RE Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso", "/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso",
); );
default.is_file().then_some(default) default.is_file().then_some(default)
} }

View File

@@ -0,0 +1,152 @@
//! Disc-wide guards for the 2D surface formats, locking in what was measured on
//! 2026-08-11. Skipped (as no-ops) when the extracted disc is absent.
//!
//! These assert *disc-wide invariants* rather than one hand-picked file, because
//! each of the findings they guard was originally missed by reasoning from a
//! sample: T8aD's "~15 % unsupported variants" were a wrong model, and LSTA's
//! "a few entries disagree with the count" was a miscount.
use std::path::{Path, PathBuf};
use sylpheed_formats::{lsta, pak::PakArchive, ratc, t8ad};
fn disc_root() -> Option<PathBuf> {
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
let p = PathBuf::from(p);
if p.join("dat").is_dir() {
return Some(p);
}
}
let default = Path::new(
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)",
);
if default.join("dat").is_dir() {
return Some(default.to_path_buf());
}
None
}
macro_rules! skip_without_disc {
($root:ident) => {
let Some($root) = disc_root() else {
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
return;
};
};
}
/// Every entry of every pak, plus every RATC child, as raw bytes.
fn for_each_blob(root: &Path, mut f: impl FnMut(&str, &str, &[u8])) {
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
.collect();
paks.sort();
for p in &paks {
let pak_name = p.file_name().unwrap().to_string_lossy().to_string();
let Ok(arc) = PakArchive::open(p) else { continue };
for e in arc.entries() {
let Ok(bytes) = arc.read(e) else { continue };
f(&pak_name, &format!("{:08x}", e.name_hash), &bytes);
if ratc::is_ratc(&bytes) {
if let Some(kids) = ratc::parse(&bytes) {
for k in &kids {
if k.offset + k.size <= bytes.len() {
f(&pak_name, &k.name, &bytes[k.offset..k.offset + k.size]);
}
}
}
}
}
}
}
/// A T8aD surface is a list of sub-rectangles, and on this disc **every** one
/// decodes. Regressing the rectangle model would show up here as a decode gap,
/// which is exactly how the old 256-grid reading looked (96 %, not 100 %).
#[test]
fn every_t8ad_on_the_disc_decodes() {
skip_without_disc!(root);
let (mut total, mut ok) = (0usize, 0usize);
let mut first_failure = None;
for_each_blob(&root, |pak, name, b| {
if !t8ad::is_t8ad(b) || b.len() < 0x40 {
return;
}
total += 1;
if t8ad::parse(b).is_some() {
ok += 1;
} else if first_failure.is_none() {
first_failure = Some(format!("{pak}:{name}"));
}
});
assert!(total > 19_000, "expected the disc's ~19 216 surfaces, saw {total}");
assert_eq!(ok, total, "first failure: {first_failure:?}");
}
/// An LSTA's header count is exact and counts **both** kinds of element: T8aD
/// sprites and `PRMD` primitives. (It was long read as unreliable because the
/// comparison ignored primitives.)
#[test]
fn lsta_count_equals_sprites_plus_primitives() {
skip_without_disc!(root);
let count_magic = |b: &[u8], magic: &[u8; 4]| {
let (mut n, mut i) = (0usize, 4usize);
while i + 4 <= b.len() {
if &b[i..i + 4] == magic {
n += 1;
i += 4;
} else {
i += 1;
}
}
n
};
let (mut lists, mut exact) = (0usize, 0usize);
let mut bad = Vec::new();
for_each_blob(&root, |pak, name, b| {
if !lsta::is_lsta(b) || b.len() < 8 {
return;
}
lists += 1;
let declared = u32::from_be_bytes([b[4], b[5], b[6], b[7]]) as usize;
let sprites = count_magic(b, b"T8aD");
let prims = count_magic(b, b"PRMD");
if declared == sprites + prims {
exact += 1;
} else if bad.len() < 4 {
bad.push(format!("{pak}:{name} declared {declared} != {sprites}+{prims}"));
}
});
assert!(lists >= 60, "expected the disc's 64 LSTA lists, saw {lists}");
assert_eq!(exact, lists, "mismatches: {bad:?}");
}
/// Nested RATC records are **leaves**: they carry no child list of their own.
/// "One level deep" describes the data, not a parser limit.
#[test]
fn ratc_nesting_is_exactly_one_level() {
skip_without_disc!(root);
let mut grandchildren = 0usize;
let mut bundles = 0usize;
for_each_blob(&root, |_, _, b| {
if !ratc::is_ratc(b) {
return;
}
let Some(kids) = ratc::parse(b) else { return };
bundles += 1;
for k in &kids {
if k.offset + k.size > b.len() {
continue;
}
let sub = &b[k.offset..k.offset + k.size];
if ratc::is_ratc(sub) {
grandchildren += ratc::parse(sub).map(|g| g.len()).unwrap_or(0);
}
}
});
assert!(bundles > 2_000, "expected thousands of RATC bundles, saw {bundles}");
assert_eq!(grandchildren, 0, "a nested RATC record listed children");
}

View File

@@ -0,0 +1,96 @@
//! The definition-object layout must keep agreeing with the disc records.
//!
//! `unit_layout` was read out of the game's loader and checked against objects
//! dumped from a running Stage 02. This pins that check: every mapped float
//! field of every identified object must equal the value its disc record sets.
//! It needs no emulator — the dump is checked in under `docs/re/captures/`.
use std::collections::BTreeMap;
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
use sylpheed_formats::unit_layout::{fields, Kind};
/// Live objects identified in the dump, with the disc record each one is.
/// `bf001` is here because full-record agreement is what identified it: the
/// four-value signature also fitted `UN_be005_ADAN_SpaceFortress`, which
/// disagrees on `Color_R`/`Color_G`.
const IDENTIFIED: &[(&str, &str)] = &[
("UN_f106_TCAF_Destroyer", "0xbd3ee300"),
("UN_f105_TCAF_Cruiser", "0xbd40e200"),
("UN_e105_ADAN_Cruiser", "0xbd3fd800"),
("UN_e106_ADAN_Destroyer", "0xbd3e6f80"),
("UN_f101_TCAF_Acropolis", "0xbd3e1f00"),
("UN_bf001_TCAF_SchlosBase", "0xbd3b6a00"),
("UN_e007_ADAN_Turret", "0xbd3dea80"),
("UN_e010_ADAN_Attacker_S", "0xbd3faa80"),
("UN_f003_TCAF_ArrowHead", "0xbd3dbd00"),
("UN_f001_TCAF_DeltaSaber_T", "0xbd3dfc00"),
("UN_f001_TCAF_DeltaSaber_T_Player", "0xbd3da100"),
];
const DUMP: &str = include_str!("../../../docs/re/captures/stage02-live-unit-definitions-deep.txt");
fn disc_root() -> Option<String> {
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
if std::path::Path::new(&p).join("dat").is_dir() {
return Some(p);
}
}
let d = "/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)";
std::path::Path::new(d).join("dat").is_dir().then(|| d.to_string())
}
/// `va -> offset -> value`, from the dump's `addr +off hex u32 f32` columns.
fn live() -> BTreeMap<String, BTreeMap<usize, f32>> {
let mut out: BTreeMap<String, BTreeMap<usize, f32>> = BTreeMap::new();
let mut cur = String::new();
for line in DUMP.lines() {
if let Some(rest) = line.strip_prefix("=== ") {
cur = rest.trim().to_string();
continue;
}
let f: Vec<&str> = line.split_whitespace().collect();
if f.len() >= 5 && f[1].starts_with('+') {
if let (Ok(off), Ok(val)) = (usize::from_str_radix(&f[1][1..], 16), f[4].parse::<f32>())
{
out.entry(cur.clone()).or_default().insert(off, val);
}
}
}
out
}
#[test]
fn mapped_fields_match_the_disc_records() {
let Some(disc) = disc_root() else {
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
return;
};
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).expect("main pak");
let live = live();
let floats: Vec<_> = fields().into_iter().filter(|f| f.kind == Kind::F32).collect();
let (mut agree, mut bad) = (0usize, Vec::new());
for entry in pak.entries() {
let Ok(bytes) = pak.read(entry) else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
let Some(id) = obj.get_raw("ID") else { continue };
let Some((_, va)) = IDENTIFIED.iter().find(|(i, _)| *i == id) else { continue };
let Some(words) = live.get(*va) else { continue };
for f in &floats {
let (Some(want), Some(got)) = (obj.get_f32(f.name), words.get(&f.offset)) else {
continue;
};
// Angle fields are degrees on disc and radians in the object.
let rad = want * std::f32::consts::PI / 180.0;
if (want - got).abs() <= want.abs() * 1e-4 || (rad - got).abs() <= rad.abs() * 1e-4 {
agree += 1;
} else {
bad.push(format!("{id}.{} disc {want} vs live +{} = {got}", f.name, f.offset));
}
}
}
assert!(bad.is_empty(), "{} fields disagree: {bad:?}", bad.len());
assert!(agree >= 400, "expected ≥400 agreeing fields, got {agree}");
}

View File

@@ -3677,6 +3677,7 @@ fn exhaust_cone_mesh() -> sylpheed_formats::mesh::GameMesh {
uvs: Vec::new(), uvs: Vec::new(),
indices, indices,
name: Some("exhaust".to_string()), name: Some("exhaust".to_string()),
vbuf_offset: None, // procedural, not read from a container
} }
} }

View File

@@ -8,7 +8,43 @@ unknown, what evidence exists, and what the first step would be. Move an item in
## Capital ships assemble wrong in the viewer ## Capital ships assemble wrong in the viewer
**Reported:** 2026-07-30, by the user. **Status:** ❔ open, not investigated. **Reported:** 2026-07-30, by the user. **Status:** ✅ **format-side cause found and
fixed 2026-08-12** — see below for the 2026-08-10 diagnosis this supersedes.
The remaining format-side defect this entry pointed at (a shared turret decoding
~100× too large in some containers) was real and is gone. `e303_wep_01` decoded
as a 1600×2100×4800 block in `Stage_S02`, swallowing the `e106` hull; requiring an
index buffer to cover its vertex pool **exactly** moved it to the block every
other container agrees on, and it now decodes 49×23×42 everywhere and places at
±179 on the hull. The same fix repaired `e106_bdy_03` (a 600×1600×998 slab) and
moved 29 anchors disc-wide, 22 of which had been carrying **another resource's
geometry under their own name**. See
[`structures/xbg7-mesh.md`](structures/xbg7-mesh.md).
Two things are worth carrying forward rather than closing:
- the assembler was **audited and exonerated** — every composite node carries
scale 1.0 and an orthonormal matrix, so nothing on that side inflates a part;
- **no metric caught this.** Coverage, cross-container consistency, the capture
oracle and the twin invariant were all green while a 1 600-unit slab sat through
the ship. It was found by *rendering the ship and looking at it*, and the
numeric screens written afterwards to automate that check both failed.
The 2026-08-10 diagnosis follows, and its viewer-side pointers still stand.
**Status (2026-08-10):** 🔎 **the format layer is exonerated.** Runtime captures of three classes (`f105`, `e105`,
`e106`) at controlled range reproduce `assemble_ship` to ≤0.43 units in translation
and to 0.000 in rotation for every part that does not move; see
[`ship-placement-capture-generalisation.md`](ship-placement-capture-generalisation.md)
§4. So look at **the viewer**: first that it passes `include_external = true`
(`iso_loader.rs:4012` — with `false` an e106 loses its bridge and both nacelles,
5 parts instead of 11), then its own transform stack.
One real format-side bug was found on the way and is **fixed**: index-less parts
(`e105_brg`) never matched their `GN_Bridge_01` hardpoint, so 34 (stage, ship) entries
`e102`, `e104`, `e105` across Stages 0229 — assembled without a bridge. The other
apparent exception (`e105_eng_01` rotation) was an aggregation artefact and is 0.000.
The original report and its reasoning follow.
The reborn viewer builds capital ships from the split XBG7 parts via The reborn viewer builds capital ships from the split XBG7 parts via
`sylpheed-formats::ship::assemble_ship`, and they come out **wrong** — parts in the `sylpheed-formats::ship::assemble_ship`, and they come out **wrong** — parts in the
@@ -44,3 +80,225 @@ class cannot hide behind `e106` passing.
handedness, node-instance recursion) is not re-breaking a correct assembly — compare handedness, node-instance recursion) is not re-breaking a correct assembly — compare
the viewer's placement against `assemble_ship`'s output directly before blaming the the viewer's placement against `assemble_ship`'s output directly before blaming the
format layer. format layer.
---
## Viewer: `include_external` is already on — that hypothesis is dead
**Checked 2026-08-11.** The item above names "first that it passes
`include_external = true` (`iso_loader.rs:4012`)" as the cheap first step. It
does: `ShipBrowser::show_external` defaults to `true`
(`iso_loader.rs:643`), the checkbox reads it (`ui.rs:1593`) and it is threaded
through `RequestShipRender``build_ship_model``assemble_ship` unchanged
(`ui.rs:1689`, `iso_loader.rs:4012`). So a ship rendered by the viewer is the
full external assembly, not the bare hull.
The viewer also does not have a transform stack of its own to blame: it bakes
`ScenePart::apply` straight into the vertices and rotates normals by the same
`p.m` (`iso_loader.rs:4030-4062`), so its placement is `assemble_ship`'s output
by construction. What remains unexcluded, in order of cheapness: the mirror
handling (`det < 0` reverses triangle winding only — a reflected part keeps its
reflected geometry), `Xbg7Model::models_named` resolving the wrong sub-model when
a resource name repeats, and the exhaust cones. **Next step is a visual**: the
diagnosis has run out of things it can settle by reading, so the viewer needs to
be run against a known-good class (`e106`) and its render compared with
`ship_render`'s.
---
## Viewer: the duplicate-resource-name hypothesis is dead too
**Checked 2026-08-11.** The diagnosis above left three candidates for why capital
ships assemble wrong in the viewer: mirror handling, `Xbg7Model::models_named`
resolving the wrong sub-model when a resource name repeats, and the exhaust
cones. The second is now **refuted**, and comprehensively.
`build_ship_model` resolves each placement with
`base.iter().find(|m| m.name == p.resource)` (`iso_loader.rs:4041`) — first match
wins — so a repeated resource name inside a container would silently draw the
wrong geometry. It cannot happen: decoding **every** XBG7 resource in **all 22
stage containers** gives **4 603 resources and zero repeated names**.
```
Stage_S01 62/62 Stage_S07 323/323 Stage_S13 290/290 Stage_S25 351/351
Stage_S02 304/304 Stage_S08 388/388 Stage_S14 22/22 Stage_S26 318/318
Stage_S03 214/214 Stage_S09 316/316 Stage_S15 386/386 Stage_S27 321/321
Stage_S04 179/179 Stage_S10 7/7 Stage_S16 65/65 Stage_S28 118/118
Stage_S05 92/92 Stage_S11 157/157 Stage_S24 162/162 Stage_S29 386/386
Stage_S06 266/266 Stage_S12 376/376
```
Per-ship it is tighter still: `e106` wants 9 distinct names and decodes exactly
9 models for 11 placements; `e105` 9 for 9; `f105` 5 for 6. Every placement
resolves to the one model it names.
**So two of the three candidates are gone** (this one and `include_external`),
leaving **mirror handling** and **the exhaust cones** — and the still-untried
visual comparison, which remains the right next step.
---
## Viewer: mirror handling and the exhaust cones are cleared too — the static avenue is exhausted
**Checked 2026-08-11.** Both remaining candidates were tested across every ship
on the disc, and neither shows the reported signature.
**Mirror handling.** The concern was that `ScenePart::apply` bakes `R·(S·v)+T`
while the viewer takes its winding-flip decision from `det(m)` alone and rotates
normals by `m` alone — both ignoring `s`. A mirror encoded as a *negative scale*
would then reflect geometry without flipping winding, drawing the part
inside-out. It never happens: across **1 485 assembled parts** in all 22
containers there are **22 mirrored parts, every one with `det(m) < 0`**, and
**zero** parts with a negative scale or a non-uniform one. `apply_twin_mirrors`
writes the reflection into `m` (negating its X column), so the viewer's flip
always fires, and ignoring `s` for normals is harmless because `s` is always
uniform.
**Exhaust cones.** These are the one piece of geometry the viewer *invents* — a
cone at each `GN_Jet`/`GN_SJet` frame, because the real engine geometry is
recessed and the game draws FX there instead. If they landed wrongly they would
read exactly as "a part in the wrong place". Across **335 assembled ships, 192 of
which have exhaust frames, not one cone sits outside its hull's bounding box**
(tolerance 10 % of the axis span).
**Caveat, stated rather than glossed:** "inside the hull box" does not prove a
cone is *right* — orientation and size are untested, and a cone could be wrong
while still inside. What it does rule out is the reported symptom for that part.
So every mechanism this diagnosis proposed is now eliminated: `include_external`,
duplicate resource names, mirror handling, and cones-in-the-wrong-place. The
format and assembly layers pass every static test available, and **the visual
comparison is no longer merely the next step — it is the only remaining one.**
Render `e106` in the viewer beside `ship_render`'s output of the same
`assemble_ship` result; if they agree, the bug is in neither and the original
report needs re-grounding against a specific ship and a specific expectation.
---
## ⚠️ DIAGNOSED 2026-08-12 — a mis-decode; the locality fix was written, then withdrawn
> Resolution at the end of this entry. Kept in full because the two wrong turns
> along the way (a "stray volume", then "monotonic anchoring") are the useful part.
## ⚠️ The format layer is NOT exonerated — but the cause is a MIS-DECODE, not a stray volume
**Found 2026-08-11 by finally doing the visual**, which the notes above kept
naming as the next step. It overturns their conclusion.
Render `e106` from the static assembly and from the baked runtime capture and
compare — `ship_render` does both:
| | placements | parts |
|---|---|---|
| runtime capture (ground truth) | **8** | `bdy_01…04`, `brg_01`, `eng_01`, `eng_02`, `wep_02_01` |
| `assemble_ship(--static)` | **11** | the same 8, **plus `e303_wep_01` ×2** and a second `e106_eng_01` |
The render makes it obvious: the destroyer sits inside a white slab that dwarfs
it ([capture](captures/e106-static-assembly-volume-bug.png)). That slab is
`e303_wep_01`, and its own geometry is:
```
e303_wep_01 172 verts, 110 tris bounds X[-1000, 600] Y[-1050, 1050] Z[-2400, 2400] 1600 x 2100 x 4800
e106_wep_02_01 1002 verts, 772 tris 269 x 179 x 417 ← what a real e106 turret looks like
e106_brg_01 202 verts, 202 tris 105 x 76 x 305
```
**110 triangles, perfectly round axis-aligned bounds, and bigger than the ship it
is mounted on.**
### CORRECTION (same day, one iteration later): it is not a volume — it is a bad decode
The first reading of this was that `e303_wep_01` is a collision/trigger volume
the assembler wrongly draws. **That is wrong, and the evidence that settles it is
decoding the same resource from every container that holds it:**
```
Stage_S01 172 verts 110 tris X[-24.5, 24.5] Y[0.0, 23.4] Z[-20.8, 20.8] ← 49 × 23 × 42, a turret
Stage_S02 172 verts 110 tris X[-1000, 600] Y[±1050] Z[±2400] ← 1600 × 2100 × 4800
Stage_S03… 172 verts 110 tris 49 × 23 × 42 (correct)
Stage_S08 … 1600 × 2100 × 4800
Stage_S26 … 1600 × 2100 × 4800
```
Same resource, same vertex and triangle count, **decoding correctly in eleven
containers and wrongly in exactly three** (`Stage_S02`, `S08`, `S26`). So:
- the **placement is legitimate**`e303_wep_01` is a small shared turret,
cross-mounted on `e101` and `e106`, and at its true size it is unremarkable;
- the original author's explanation of the capture's silence (**vbase dedup**)
stands, and my "dedup would show one, not zero" objection does not survive:
with the correct decode the turret is small, ordinary geometry;
- **the defect is in the mesh decoder**, which resolved this resource's vertex
data differently in three containers.
The render and the symptom are real; the cause named in the first version of this
entry was not.
### The part that matters more than this one resource
**The decoder can produce wrong geometry without declining.** The
[XBG7 audit](structures/xbg7-mesh.md) counted 814 resources it *refuses* — a
visible, honest failure. This is the other kind: `e303_wep_01` decodes "fine" in
`Stage_S02` and is silently 100× too large. Screening for the signature (bounds
that are exact multiples of 50 with a span over 1000) flags 2232 models in each
of `S02`, `S03`, `S08`, `S26`, `S27` — **but that screen also catches legitimate
`e_rou_*` composite proxies**, so it is a candidate list, not a count of bugs.
**Next:** diff the anchor scan's chosen `vb0` for `e303_wep_01` between
`Stage_S01` (correct) and `Stage_S02` (wrong) — same resource, two outcomes, so
the divergence is directly observable — then use whatever distinguishes them to
add a post-decode sanity check, so a silent 100× mis-decode becomes a decline.
### Why this was missed
`assemble_ship` treats **every** `rou_*` node in the composite as a drawable
part, and the doc comment states the cross-id mount as intended behaviour —
`"INCLUDING repeated instances and cross-id turret mounts (rou_e303_wep_01_root
×2 on the e106 hull)"` — with
`ship::tests::static_assembly_matches_runtime_capture` asserting
`count("e303_wep_01") == 2`. The absence from the capture was explained away as
vbase dedup, but **dedup would show one instance, not zero**.
The test cannot catch it either: it walks the capture's parts and looks each up
in the static output, so **extra** static placements are invisible to it. That is
the same shape of gap as the earlier `include_external` hypothesis — a test that
can only fail one way.
### Scope, stated carefully
Sweeping all 335 assembled ships for the signature *ship-scale span with under
400 triangles* flags **20 ships and 58 placements** over 28 distinct resources
(`e005_ant_*`, `f001_ant_*`, `f002_bdy_*`, `f301_barrel`, `f303_body`,
`e303_wep_01`, …). **Only the `e106`/`e303_wep_01` case is proven** — by render,
by capture absence, and by geometry. Some of the others may be legitimately large
low-poly parts, and each needs the same three checks before being called a bug.
**Still true, and independent of the correction above:**
`static_assembly_matches_runtime_capture` walks the capture's parts and looks each
up in the static output, so **extra static placements can never fail it**. That is
worth fixing regardless — it is the same one-way-test shape as the earlier
`include_external` hypothesis.
Also unchanged: only **two** cross-id placements exist fleet-wide (`e303_wep_01`
on `e101` ×24 and `e106` ×36, across 335 assembled ships), so cross-id mounting is
a narrow, real feature rather than a systemic guess.
---
## Resolution (2026-08-12)
`anchor_pool_mesh` took the **first** candidate in file order from a
container-global scan, so a resource could be handed another resource's block
whenever both shared `(stride, vertex count, index count)`. Fixed by anchoring
each resource near its **descriptor neighbours** (two-pass: learn, then re-anchor).
- it took inconsistency **125 → 51** with coverage unchanged, and made `e106`
render correctly ([after](captures/e106-static-assembly-fixed.png))
- **but it flipped the `e106` twin-mirror decision**, which
`static_assembly_matches_runtime_capture` (ISO-gated, so it skips in a plain
`cargo test`) catches against the runtime capture — so it was **reverted**
- the user-reported "capital ships assemble wrong" is therefore **diagnosed, not
yet fixed**; see [xbg7](structures/xbg7-mesh.md) for what the real fix needs
Still open from this entry: `static_assembly_matches_runtime_capture` walks only
the capture's parts, so **extra** static placements still cannot fail it.

View File

@@ -13,31 +13,37 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
| IPFB `.pak` archive | ✅ | `sylpheed-formats/src/pak.rs` + `tests/pak_idxd_disc.rs` | header + 12-byte TOC, Z1/zlib payloads | | IPFB `.pak` archive | ✅ | `sylpheed-formats/src/pak.rs` + `tests/pak_idxd_disc.rs` | header + 12-byte TOC, Z1/zlib payloads |
| name-hash (TOC keys) | ✅ | `sylpheed-formats/src/hash.rs` | Barrett-reduction hash; recovers original paths | | name-hash (TOC keys) | ✅ | `sylpheed-formats/src/hash.rs` | Barrett-reduction hash; recovers original paths |
| IDXD object/table | ✅ | `sylpheed-formats/src/idxd.rs` | self-describing; ship/weapon stats verified vs known values | | IDXD object/table | ✅ | `sylpheed-formats/src/idxd.rs` | self-describing; ship/weapon stats verified vs known values |
| XPR2 texture + cubemap | 🟡 | `sylpheed-formats/src/texture.rs` | de-tile + A8R8G8B8; **colours unverified** (dynamic item) | | XPR2 texture + cubemap | 🟡/✅ | `sylpheed-formats/src/texture.rs` + [colour check](xpr2-colour-check.md) | de-tile + A8R8G8B8 and DXT1. **Channel order ✅ confirmed against the running game**: the Delta Saber's decoded atlas is orange-dominant (median saturated hue 23.3°, *zero* cool pixels) and the game renders the same hull at 9.3° — a red↔blue swap would sit at ≈200°. Exact fidelity (gamma/sRGB curve, premultiplied alpha, per-channel scale) is 🟡 untested, since a hue comparison cannot see it; cubemap face ordering ❔ |
| T8aD 2D texture | 🟡 | `sylpheed-formats/src/t8ad.rs` | ~85% decode; **colours ✅ CONFIRMED** ([k8888](structures/texture-color-k8888.md)); ~15% variants deferred | | T8aD 2D texture | | `sylpheed-formats/src/t8ad.rs` | **100 % of the disc decodes** (19 216/19 216, measured). The "~15 % deferred variants" were a wrong model, not a variant: a surface is a list of **arbitrary sub-rectangles**, each with a 16-byte header of `dst X, dst Y, width, height`, not a 256×256 grid — `0x1c` is the **rectangle count**. Uncovered area stays transparent. **Colours ✅ CONFIRMED** ([k8888](structures/texture-color-k8888.md)) |
| RATC bundle | 🟡 | `sylpheed-formats/src/ratc.rs` | child listing confirmed; one level deep | | RATC bundle | | `sylpheed-formats/src/ratc.rs` | child listing confirmed. **"One level deep" is not a limitation — there is nothing deeper**: 2 859 bundles hold 18 002 children at depth 1 and **0 at depth 2**, with no parse failures. Nested RATC blobs are **leaf records that reference siblings by name** (`opt `, the sprite name): 3 311 leaves, all embedding sibling names, **10 144 of 10 148 references resolve**. The 4 that do not are one dangling asset — `pmbase.rat``pmbase.t32` in `GP_STAGE_CLEAR.pak`'s four language builds, and `pmbase.t32` is **on the disc nowhere** |
| LSTA sprite list | 🟡 | `sylpheed-formats/src/lsta.rs` | inline T8aD frames | | LSTA sprite list | | `sylpheed-formats/src/lsta.rs` | A display list of inline elements: **T8aD sprites and `PRMD` primitives**. The `count` at `0x04` is **exact and counts both**`count == T8aD + PRMD` for **64/64** lists on the disc, which retires the old "a few entries disagree" note (it compared sprites against a total including primitives). **All 1 281 sprite frames decode** after the T8aD rectangle-list fix |
| IXUD subtitle | 🟡 | `sylpheed-formats/src/ixud.rs` | timed cues; **movie↔track link unknown** (dynamic item) | | IXUD subtitle | 🟡/✅ | `sylpheed-formats/src/ixud.rs` + [movie link](movie-subtitle-link.md) | timed cues. **The movie↔subtitle↔voice link is solved — statically**, from the movie config record in `tables.pak` (schema `0x067025b9`), not from the running game as this row previously assumed: [101 movies mapped](captures/movie-subtitle-voice-map.csv), 94 with subtitles, 83 with voice, 21 with a telop overlay. 93 of 94 subtitle refs resolve in the language paks; **`SUBTITLE_S12B.tbl` is missing from all six languages** — a dangling reference on the disc. Naming is `SUBTITLE_<base>.tbl` / `VOICE_<base>` with six documented exceptions. The record's ~104 **script ids** are ❔ — positional pairing drifts by three because the IDXD pool dedupes repeated values |
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser | | Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
| XBG7 mesh | 🟡/❔ | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | weapons/props: declaration-driven variable stride (36 models), GPU-confirmed. **Stage containers: 5662 sub-models across 22 stages** via content-anchored grouped pools (`stage_models`). Quantized hero bodies (DeltaSaber `f004`) still declined | | XBG7 mesh | ✅/🟡 | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | **6 294 resources, 6 209 decode (98.7 %), 82 searched-and-missed** (2026-08-12, up from 5 480 / 87.1 %). Five evidence-driven fixes got there: **distinct anchor assignment** (no two resources may claim one buffer — proved by a capture showing the container holds both mirrored `e106` hull halves), the connectivity cap replaced by a **winding-consistency gate at 0.70**, **structural requirements on pre-pivot sub-meshes** (index range, then exact pool coverage), and **filtering after the assignment** so a subset query cannot differ from the full decode. Validated against a runtime capture that names the file offset of every buffer the engine drew: **46/46 drawn buffers claimed, 45 anchored exactly**. **No real mesh now decodes differently in different containers** — all 89 remaining cross-container disagreements are interchangeable 24-vertex bounding boxes, which no anchoring rule can pin (monotone order re-tested and refuted). Remaining misses attribute to the degeneracy/extent gate (42), winding (31) and coverage (9); the first was probed and its "obvious" fix refuted. Every decoded sub-mesh covers its own vertex pool. **The `[index buffer][vertex buffer]` layout is now runtime-verified** (2026-08-13): with the F10 capture extended to log each draw's index buffer, all **42** drawn `Stage_S02` buffers match our decoded index count exactly, all 42 have their index union cover the pool exactly, and the 30 single-block cases all sit at `pad ≤ 3` — so `e106_eng_02_l`'s old rejection was the connectivity gate, not a misplaced index buffer. The `indices=` mystery was the capture keeping only the **first of several index batches** per buffer. **And comparing index VALUES found the biggest silent defect yet**: the anchor took the first `pad` that validated, so a block whose index data sits at pad 2 was read **one element late** — 76/93 captured runs matched, all 17 differences a one-element shift. Scoring pads by degenerate triangles + winding fixes it: **93/93** captured runs now match byte for byte, disc-wide degenerate runs **582 → 1** (the grouped path had the same bug; and two resources were anchored on a degenerate lookalike earlier in file order), **590 of 8 850** sub-meshes re-wired with 10 vertex anchors moved, resources decoded unchanged at 6 209. Cross-container minority decodes 89 → 96 — *because* the decoder improved: `_rou_f402_dead` now has a majority (32×25×8) so its seven wrong copies are named instead of hidden. One dirty run remains, blocked by distinct assignment on a 24-vertex box. **Then the descriptor gave up its last structural secret**: it declares a vertex layout **per sub-mesh** (`n201_01` → strides 24/24/24/**28**, capture-confirmed), and grouped selection must prefer the candidate explaining the **whole** pool rather than the first whose pivot validates — together they take never-decoding resources **85 → 47** (**6 247 / 6 294 = 99.25 %** decode), put `n201_01` on all four capture-proven offsets and raise the stage-05 capture oracle to **128/128**. The residual 47 is 30 pose/proxy composites (0.010-unit marker boxes), 6 `.DAT` particle composites, 8 damage/LOD variants and 3 props — not a threshold away |
| Capital-ship part placement | 🟡 | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | hull placement static-exact; external parts approximate statically. **Runtime capture** (Canary F10 → VS-constant WorldView) gives ground truth — validated on `e106` destroyer; not yet baked into the viewer | | Capital-ship part placement | | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | Placement is **sound** (hull static-exact against the `e106` capture; cross-id mounting genuinely narrow, 2 pairs across 335 ships). The XBG7 mis-decode this row used to blame for "ships assemble wrong" — a shared turret ~100× too large in some containers — is **fixed** (2026-08-12, the exact-coverage requirement): `e303_wep_01` now decodes 49×23×42 everywhere and places at ±179 on the `e106` hull, and no real mesh disagrees across containers. A composite-node audit confirmed the assembler itself never applied a bad scale (all nodes scale 1.0, orthonormal). Still open: `static_assembly_matches_runtime_capture` walks capture parts only, so **extra** static placements cannot fail it |
| Weapon fields defaulted on disc | ✅ | [runtime struct](structures/weapon-struct-runtime.md) · [DATA SHEET route](weapon-datasheet-runtime.md) | **Solved.** Canary maps guest RAM into `/dev/shm`, so the parsed `Weapon`/`Shell` objects are readable live; their layout is solved against disc ground truth (zero contradictions over 100+ records). All 126 weapons, exact numbers, no story progress needed — [4 393 values](captures/weapon-runtime-fields.csv) the disc does not carry. Supersedes the letter-bucket limit of the DATA SHEET route, which now serves as the independent cross-check | | Weapon fields defaulted on disc | ✅ | [runtime struct](structures/weapon-struct-runtime.md) · [DATA SHEET route](weapon-datasheet-runtime.md) | **Solved.** Canary maps guest RAM into `/dev/shm`, so the parsed `Weapon`/`Shell` objects are readable live; their layout is solved against disc ground truth (zero contradictions over 100+ records). All 126 weapons, exact numbers, no story progress needed — [4 393 values](captures/weapon-runtime-fields.csv) the disc does not carry. Supersedes the letter-bucket limit of the DATA SHEET route, which now serves as the independent cross-check |
| Unit (craft/vessel) fields defaulted on disc | ✅/🟡 | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — [values](captures/unit-runtime-fields.csv) | | Unit (craft/vessel) fields defaulted on disc | ✅/🟡 | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. **Re-derived independently 2026-08-13 from the loader's own key strings** (`sub_82341A20`; the field name for each store is a string in the image): **159 fields**, agreeing with this solver on **25 of 25 shared offsets**, verified at **406 values matching the disc and 0 disagreeing** over 11 live objects spanning UNIT and VESSEL — landed as `data/unit_definition_layout.txt` + `sylpheed_formats::unit_layout` + a no-emulator test, with **121 defaulted fields** read out ([live-unit-definitions](live-unit-definitions.md)). Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — but a defaulted field is **not** a global constant: `Size_Y` provably inherits `Size_X` (7 independent units, 6 distinct values), and three more sibling rules are recorded ❔, recovering 65 values in units never visited — [values](captures/unit-runtime-fields.csv) |
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` (screen-level draw order) not yet decoded | | Arsenal develop economy | ✅/ | [arsenal-develop-economy](arsenal-develop-economy.md) + [conditions](captures/arsenal-develop-conditions.csv) | The Arsenal reads `weapon.tbl` (item ids, in the 8-category display order) and `strings.tbl` (names, descriptions, and a **"Conditions to obtain"** block per item) out of `GP_HANGAR_ARSENAL.pak`. All **60** conditions are extracted: gates are stage completion, a predecessor item, or an **ace kill**; costs run 3 000350 000 P and **20 items are free** once gated. `weapon.tbl`'s first record reproduces the in-game DATA SHEET exactly (Range D / Power E / Speed / Weight 0.3 = Light / 4000 P) — later records are unreadable from the string pool alone because IDXD **dedupes repeated values**. Used to identify the save blob's index space, now **solved**: the blob follows **`strings.tbl`'s** order — the display order *plus* the cut items only the localisation file lists (`Adhesive Mine B2A`, `Ballista GSH`, …) — pinned by four hand-written probe saves (9 Stiletto, 21 Falcon, 39 Tomahawk, 48 Jamming System) and closing exactly at index 53. `weapon.tbl`'s id list is **not** the index space; that it is also 54 long is a coincidence, and the two agree only to index 32. The retail save's five unexplained owned entries are the cut items, shipped owned and never rendered |
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` is decoded — it is a **looping sprite animation**, not a composition. The **screen's draw list is the RATC bundle's own declaration table** (elements in back-to-front order, including the `eff*`/`deli*`/`msg` sprites that have no `.rat`, and excluding focused button variants reached via `opt `); its entry also carries a **parent element index** at `+32`. **A screen is fully reconstructible from its bundle**: the placement region right after the declaration table gives every element a keyframe group (header = element index + keyframe count, then 40-byte blocks of scale/tint/X/Y), including the `.rat`-less sprites — verified 11/11 on the tutorial pause bundle, with `pgp_ttrl_btn10`'s inline (546,288) matching its own record exactly |
| Save file (`savedata`) | ✅/❔ | [savegame-format](structures/savegame-format.md) + [`tools/re-capture/savegame.py`](../../tools/re-capture/savegame.py) | `GDHA` container, zlib payload, chunk stream (`GDAA` / phase name / `GHAD` 122 B progress block / 16×20 B slot table / trailer). **Container and layout read off the title's own serializer `0x822C00E8` and verified by a byte-identical round-trip**; the whole save is 545 B. Payload offsets are also the live save object's offsets (`save+8` GHAD, `save+136` slots). A second save made in-game names **Points** (+24), **flight time in ms** (+4) and **clear ratio %** (+8) off the game's own Details panel; the payload is a **pure function of game state** (same state saved twice = byte-identical, only the header FILETIME and its uninitialised pointer padding move), and the 16 `SHAB` records are **not** the UI's 20 save slots. Difficulty vs stage is undecided — three fields hold 2. **A third save, taken after developing exactly one Arsenal weapon** (Light Machine Gun MG I, 4000 P), moves exactly three things: `+24` Points 4101→101 (which **separates it from `+28`**, that did not move), `+8` clear ratio 5→6 (so the ratio counts *collection*, not only stages), and two entries of the 54-byte blob — `2→4` for the item bought and `0→2` for the successor the game announced as newly developable, giving the blob its alphabet ✅ *0 locked / 2 developable / 4 developed* (only the `4`s are stored — `2` is re-derived at load). **Saves can also be written back**: three derived header fields (length at `+0x30`, payload length at `+0x8c`, `adler32` at `+0x8e`) are all that stand between a parse and a hand-written save that the title loads, and [`savegame_edit.py`](../../tools/re-capture/savegame_edit.py) re-wraps a real save byte-identically. That turned the blob's index space from blocked-on-story-progress into four probe saves — see the [economy note](arsenal-develop-economy.md) |
## Runtime / dynamic-capture technique ## Runtime / dynamic-capture technique
| Technique | Conf. | Spec | Notes | | Technique | Conf. | Spec | Notes |
|-----------|-------|------|-------| |-----------|-------|------|-------|
| Scripted input without a virtual controller | ✅ | [`tools/re-capture/pad.py`](../../tools/re-capture/pad.py) + Canary `--hid=file` | The old `vgamepad` path created its device through `/dev/uinput`, which is **not namespaced** — a pad made inside the container registers with the HOST's input stack, so every scripted press leaked to the desktop. Canary now carries a header-only driver that reads pad state from a **text file** (`--hid=file --pad_file=…`): no kernel device, nothing leaves the container, and analogue values are exact. ⚠️ The trap: 360 menus poll **`XamInputGetKeystrokeEx`**, not `GetState` — with `GetKeystroke` stubbed the pad looks completely dead on a title screen while its own log shows the press arriving. Implemented edge-triggered, no auto-repeat (scripted input wants one event per press). Proven end to end: boot → title → main menu → EXTRAS from the file alone |
| Live guest-memory write | ✅ | [`tools/re-capture/gpoke.py`](../../tools/re-capture/gpoke.py) | Write companion to `gmem.py`, same guest-VA → `/dev/shm` map; prints before/after per word. Used to confirm the challenge gate's cleared-stage mask on the running game |
| Live guest-memory read | ✅ | [`tools/re-capture/gmem.py`](../../tools/re-capture/gmem.py) | Canary backs the guest address space with `/dev/shm/xenia_memory_*`; guest VAs map in through Xenia's fixed table. Full-RAM search ~0.2 s (sparse, `SEEK_DATA`). No debugger, no emulator patch, game keeps running | | Live guest-memory read | ✅ | [`tools/re-capture/gmem.py`](../../tools/re-capture/gmem.py) | Canary backs the guest address space with `/dev/shm/xenia_memory_*`; guest VAs map in through Xenia's fixed table. Full-RAM search ~0.2 s (sparse, `SEEK_DATA`). No debugger, no emulator patch, game keeps running |
| IDXD object layout solver | ✅ | [`tools/re-capture/weapon_runtime.py`](../../tools/re-capture/weapon_runtime.py) | Scan RAM for a class's vtable → enumerate its objects → brute-force `(field, offset, encoding)` against the disc records. Accepts a binding only on **zero** contradictions. Generalizes to any IDXD-backed definition | | IDXD object layout solver | ✅ | [`tools/re-capture/weapon_runtime.py`](../../tools/re-capture/weapon_runtime.py) | Scan RAM for a class's vtable → enumerate its objects → brute-force `(field, offset, encoding)` against the disc records. Accepts a binding only on **zero** contradictions. Generalizes to any IDXD-backed definition |
| Live entity state, anchored on the definition | ✅ | [`tools/re-capture/own_state.py`](../../tools/re-capture/own_state.py) · [autopilot](autopilot-memory-driven.md) | An undamaged craft holds its definition's own numbers, so a *solved definition field* locates the matching live field without a value scan: definition `HP` (1500) → **hull at `position+0x154`**, confirmed by a trace across a death (30/60/90 per hit, negative at 0). Reusable for any live counter whose maximum the definition carries | | Live entity state, anchored on the definition | ✅ | [`tools/re-capture/own_state.py`](../../tools/re-capture/own_state.py) · [autopilot](autopilot-memory-driven.md) | An undamaged craft holds its definition's own numbers, so a *solved definition field* locates the matching live field without a value scan: definition `HP` (1500) → **hull at `position+0x154`**, confirmed by a trace across a death (30/60/90 per hit, negative at 0). Reusable for any live counter whose maximum the definition carries |
| Mission / escort state, every entity's hull | ✅ | [`tools/re-capture/mission_state.py`](../../tools/re-capture/mission_state.py) · [escort state](mission-escort-state.md) | `hull = position + 0x154` is a property of the **entity class**, not of the player object: at t=0 it equals each entity's own definition `HP` across 7 classes and 5 distinct HP values (turret 100, fighter 500, destroyer 10000, cruiser 30000, **ACROPOLIS 25000**), falls under fire (780 damage events in 240 s), goes negative at death, and the object then leaves the heap. So an escort objective is scoreable live — `UN_f101_TCAF_Acropolis` measured at 25000 → 23038 over 240 s, attack starting only at t≈170 s. `REMAINING OB` counts objectives, not hostiles (012 on the HUD vs 118 live ADAN); its address is still ❔ |
| In-flight control mapping | ✅/🟡 | [`tools/re-capture/fire_probe.sh`](../../tools/re-capture/fire_probe.sh) · [controls](flight-controls-runtime.md) | Measured by holding each pad input and photographing the HUD ammo counters: **`RB` = nose gun** (6000→5956 in 4 s, ~11 rounds/s, HEAT rises), **`Y` = main mount** (missiles, 300→299), d-pad = **tactical map** overlay, nothing else moves a counter. No target-cycle input exists — the `TARGET` marker is present with nothing pressed, so targeting is automatic and a missile lock is **time-on-target**. That, not target choice or ballistics, is what caps lethality at 2 kills per 98 missiles |
| Throttle → speed law | ✅/🟡 | [flight-speed-law](flight-speed-law.md) | **The throttle selects a TARGET SPEED**, it does not add thrust: no input settles at ~420 (`CruisingVelocity` 350), `RT` at ~1 530 (`MaximumVelocity` 1200), `LT` at ~125 (`MinimumVelocity` 100), and releasing either returns to cruise. `Acceleration`/`Deceleration` govern the convergence rate (measured ~440560 units/s² against 500/600). 🟡 measured world speeds run ≈1.21.3× the definition numbers while the HUD shows the definition value exactly, so world units are a constant (~1.25) multiple of the definition's velocity unit |
| Input → dynamics calibration | ✅ | [`tools/re-capture/ctrl_probe.py`](../../tools/re-capture/ctrl_probe.py) · [`binq.py`](../../tools/re-capture/binq.py) | Hold each pad input in turn and measure the craft's speed as displacement/s of its own position triple — no speed field needed first. Settled the throttle: **`RT` accelerates, `LT` brakes, and the setting persists** (488 → 1510 → 174 units/s), overturning an earlier field-scan conclusion | | Input → dynamics calibration | ✅ | [`tools/re-capture/ctrl_probe.py`](../../tools/re-capture/ctrl_probe.py) · [`binq.py`](../../tools/re-capture/binq.py) | Hold each pad input in turn and measure the craft's speed as displacement/s of its own position triple — no speed field needed first. Settled the throttle: **`RT` accelerates, `LT` brakes, and the setting persists** (488 → 1510 → 174 units/s), overturning an earlier field-scan conclusion |
## Functions / code paths ## Functions / code paths
_None documented yet — populated during the dynamic-RE phase._
| Function | Conf. | Reimpl. | Summary | | Function | Conf. | Reimpl. | Summary |
|----------|-------|---------|---------| |----------|-------|---------|---------|
| — | — | — | — | | Achievement table + XAM read path | ✅/🟡 | [achievements](structures/achievements.md) | The XEX's `XACH` resource (`.pe` `0x8FBCBC`, 36-byte records) defines **24 achievements summing to 1000G** — the retail total, which self-checks the stride and field offsets. `GamePart_Debriefing` (`0x8218CF38``0x82191B18`) does two things: it walks the on-disc `ACHIEVEMENTS_REQUIREMENTS` list (`tables.pak` #16, entries `ACHIEVEMENT01…24`, in achievement-id order — its `ShootDownAircrafts`/`ShootDownShips`/`ShootDownWeight`/`GetAllWeapons`/`GetAllAchievements` types line up with ids 1924 exactly as `XACH` names them), evaluating each and setting a bit; **and it enumerates `XACHIEVEMENT_DETAILS` from XAM** — 36-byte records confirmed by the `0x38E38E39`+`srawi 3` divide-by-36, `dwId` at `+0`, `dwFlags & 0x00020000` (`…_ACHIEVED`) at `+32`, behind a waited-then-closed async handle. **So earned state comes from the console profile, not the 545-byte save** — and the masks it builds are `1 << dwId`, i.e. **bit = the 1-based id**. **The challenge missions do NOT gate on this** — an earlier claim here, refuted by finding `+80`'s sole writer: it is `GamePart_StageClear` setting `1 << stage`, so that word is a *cleared-stage* mask and the shared "24" was a coincidence. `GetAllAchievements`/`GetAllWeapons` are requirement **types**, not debug cheats |
| Challenge-mission gate + stage-config switch | ✅ | [challenge-mission-gate](challenge-mission-gate.md) | **`GamePart_ChallengeMission` gates each of the six challenge missions on a CLEARED-STAGE bit**: `REQUIREMENT` absent or `"Always"` → available, else `n = atoi(v)` and it tests bit `n` of singleton `+80` (`n < 24`) or bit `n-24` of `+1956` (`n ≥ 24`) — which is the disc's own stage numbering (story 116 and tutorial 1823 below 24, challenge 2429 above). Word A's **sole writer** is `0x821C1820` in `GamePart_StageClear`, doing `1 << (this+84)` where `this+84` is the stage number (it also indexes a 20-byte per-stage record array and the debriefing `STAGE` sprite list). So `TimeAttack` needs **stage 16** — the last story mission — and the rest chain off challenge stages 2529. The six-mission table is on disc in `tables.pak` (schema `54a10697`). Separately, the stage loader picks its config section from a **mission-kind field at `object+144`**: `3``EXTRA`, `5`/`6``CHALLENGE`, anything else → `FILE`; two further sites treat `{3,5,6}` as one class. Constructed as `0` (`sub_821783D8`) and only ever *cleared* inside the class, so the kind comes from the launching GamePart, **not** from the stage number — which is a mechanism (🟡, unproven) for why patching the save's stage field to a challenge stage kills the load. Same note carries the **GamePart id table** (`0x820A1630`, 29 ids, `GP_CHALLENGE` = 26, cross-checked against the image's own `RegisterToFactory<26, …>` text) and the disc's **three stage families**`S01``S16` story, `S18``S23` tutorial, `S24``S29` challenge, plus `Test`, matching `weapon.tbl`'s 16 + 6 + 6 key set exactly. `GP_CHALLENGE.pak` holds **0 IDXD objects** — it is the menu screen; challenge missions reuse `GP_MAIN_GAME_E.pak`'s stage records |

View File

@@ -0,0 +1,69 @@
# Session log — 2026-08-11 (autonomous run)
What changed today, in the order a reviewer would want it, with the one item
that needs **your decision** called out first.
## Needs a decision from you
**Editing save slot 01.** Save files can now be written back
([savegame_edit.py](../../tools/re-capture/savegame_edit.py) re-wraps a real save
**byte-identically**), and hand-written saves load. That unblocked several
questions by probing throwaway slots. But the **title-menu screens read the
auto-save (slot 01)**, so the remaining save-field questions (the difficulty
enum, `SHAB.b`) need slot 01 itself edited — the only save with real progress.
Not done. Recoverable (full content backup + slot 01's bytes archived), but it is
your call. Everything else below was done in throwaway slots.
## Findings, by area
### Save format
- **The develop differential**: buying one Arsenal weapon moves exactly three
fields — Points `+24` (which **separates it from `+28`**), clear ratio `+8`
(so the ratio counts *collection*, not stages), and two entries of the 54-byte
blob. Blob alphabet: `0` locked / `2` developable / `4` owned; only the `4`s
are stored, `2` is re-derived at load.
- **The blob's index space is solved** — it follows `strings.tbl`'s item order,
**cut items included**, pinned by four probe saves and closing exactly at 53.
- **`SHAB` is the per-stage record table**: record 0 = Stage 01, and `c` is its
best clear time in ms (`324773` = the `05:24.77` MISSION SELECT shows).
- **Refuted**: every GHAD scalar as stage/difficulty (16 elements probed), and
"stage = filled `SHAB` count + 1".
### Formats
- **T8aD 96 % → 100 %** of the disc: a surface is a list of **sub-rectangles**
(`dst X, dst Y, w, h` per rectangle), not a 256 grid. The "~15 % variants" were
a wrong model.
- **LSTA**: the count was never unreliable — it counts **sprites *and* `PRMD`
primitives** (64/64 exact).
- **RATC**: "one level deep" is not a limitation; nested records are **leaves
that reference siblings by name** (10 144/10 148 resolve).
- **UI screens are fully reconstructible from their bundle** — element list +
order (declaration table, with a **parent index**) and placement/animation
(keyframe region). **Validated against the running game to ±2 px.**
- **Movie ↔ subtitle ↔ voice link solved statically** (101 movies) — `INDEX` had
it filed as needing the running game.
- **XPR2 channel order confirmed** against the game (hue 23.3° vs 9.3°).
### Meshes — the user-reported viewer bug
- Root-caused, **after one wrong turn I corrected in place**: the giant slab on
`e106` is not a stray volume but an **XBG7 mis-decode** — the same resource is a
49×23×42 turret in 11 containers and 1600×2100×4800 in 3.
- **New detector**: a resource shared across containers must decode to the same
bounds. **125 of 681** shared resources fail that check — a measured lower
bound on *silent* mis-decodes (distinct from the 814 honest declines).
## Two disc defects found
`SUBTITLE_S12B.tbl` (referenced by the movie table, present in **no** language)
and `pmbase.t32` (placed by `pmbase.rat`, present **nowhere**). A reimplementation
should skip a missing asset rather than treat it as a decode failure.
## Process notes worth keeping
- Several "the data is inconsistent" comments meant **our model was incomplete**
(T8aD variants, the LSTA count). Re-test such notes rather than routing around
them.
- A test that walks the *oracle's* items and looks each up in our output **can
only fail one way** — it never sees extras. That shape hid the ship bug and,
earlier, `include_external`.
- When a diagnosis has eliminated every hypothesis and the last step is *a
visual*, do the visual first. Three iterations went by before a 30-second
render showed it.

View File

@@ -0,0 +1,255 @@
# The Arsenal develop economy, and what the save's blob indexes
**2026-08-11.** The save file's 54-byte blob was shown to be per-item development
state by [a runtime differential](structures/savegame-format.md#the-develop-differential-one-weapon-three-fields) —
one weapon bought, two entries moved, `2 → 4` for the item and `0 → 2` for the
successor. That named the *values*. This note names the *index space*, and in
passing recovers the whole develop economy from the disc.
## Where the Arsenal gets its data
`dat/GP_HANGAR_ARSENAL.pak` carries one screen-config record per language whose
fields point at the rest:
```
PATH = dat\GP_HANGAR_ARSENAL.pak+eng\
WEAPON = weapon.tbl STRINGS = strings.tbl
WEAPON_CATEGORIES = 8 Detail_Window_Known / Detail_Window_Unknown
ConditionToDevelop = 757,228 WeaponDesc = 734,502 Range = 891,73 …
```
`WEAPON_CATEGORIES = 8` is the eight tabs the screen cycles with LB/RB (GUN,
BEAM, LASER, MULTIPURPOSE MISSILE, ANTI-SHIP MISSILE, BOMB/ROCKET, CANNON,
SPECIAL). The `…= x,y` fields are screen positions for that screen's labels —
`ConditionToDevelop` is *where the conditions text is drawn*, not a condition.
### `eng\weapon.tbl` — the item table (IDXD, schema `0x03c244b5`, 66 records)
Its string pool opens with a stage list, then the literal key `WEAPONS`
followed by the item ids **in table order**:
```
No_Equipment, Machiene_Cannon_MG1, Machiene_Cannon_MG2H, Machiene_Cannon_MG3,
Machiene_Cannon_MG5H, Broad_Sword_SG1, Swing_Sword_SG2H, Twin_Sword_SG3II,
Frail_GP37, Stiletto_BG1, Dagger_BG2, Rapier_BG4H, Pilum_BP, … , Wep_83,
Wep_84, Wep_85, NullWeapon_Arm1, NullWeapon_Arm2, NullWeapon_Arm3
```
The ids are grouped by the eight categories in the same order the screen shows
them — verified item-for-item against the running game (see the all-owned probe
below). Note one **coincidence that cost time**: the run `No_Equipment … Wep_83`
is also 54 entries, the save blob's length, which invites the conclusion that this
list *is* the blob's index space. It is not — the two agree only up to index 32.
The blob follows `strings.tbl`'s order, which carries items this list omits.
Each item then has a record: `Size, Weight, Range, Power, Speed, Stage,
MissionObjective, Dependency, Points`. For the **first** record all of it reads
straight out, and it matches the running game exactly:
| field | disc | in-game DATA SHEET for Light Machine Gun MG I |
|---|---|---|
| Range | `D` | `Range Class D` |
| Power | `E` | `Damage Class E` |
| Speed | `-` | `Speed Class ` |
| Weight | `0.3` | `Weight Class Light` |
| Points | `4000` | `4000 P` on the develop row |
**Caveat, and it is a real one:** IDXD string pools store each distinct string
*once*, so from the second record onward every value that repeats an earlier
string is absent from the token stream. Record 2 (MG2H) shows only its unique
values — `0.4` and `3000` — and record 6 shows no number at all because its cost
had already been seen. So `weapon.tbl`'s per-item fields cannot be read from the
pool alone; they need the binary node/index region, which is still undecoded
(see the module docs in `idxd.rs`). The prose table below is the practical
substitute.
### `eng\strings.tbl` — the economy in prose (IXUD, UTF-16BE)
The same pak's string table holds, per item: the internal id, the display name,
the description, and a **"Conditions to obtain …"** block that spells out the
gate and the price:
> Conditions to obtain Light Swivel Machine Gun MG2H — Completed development of
> the Light Machine Gun MG1 — Must spend 3000 points to develop
All 60 of them are extracted to
[`captures/arsenal-develop-conditions.csv`](captures/arsenal-develop-conditions.csv)
with the stage gate, the cost and the prerequisite split out. The shape of the
economy:
- gates are **stage completion** (`Complete Stage 1 … 15`), **a predecessor
item** (`Completed development of the …`), or **an ace kill** (`Shoot down the
ace pilot in Stage 11/13/14`);
- costs run 3 000 → 350 000 points, and **twenty items have no cost at all**
(`Tomahawk Alpha Rail Gun — Complete Stage 1`). Those are still *bought*, for
**0 P** — see the probe below, which caught one at `0 P` — which is why they
read as `Developed` in a list the player never spent points in;
- two items are anomalies worth flagging: `Adhesive Mine B2A` says "Must spend
points to develop" with **no number**, and ten late entries (`Ballista GSH`,
`Designator LH`, `Smoke Marker Launcher`, `Bumble UV`, `Ballista 24 Rocket
Launcher`, `Thrush 220AM`, `Divider L3GP`, `Mace GP25H`, `Thor Gun System`,
`Spitfire BX`) have a conditions *header* and no conditions — ❔ cut content or
non-player weapons.
## How the first 32 entries were confirmed
Predicting the save state from the conditions and comparing against the actual
blob is a strong test, because it is made **before** looking at the blob. At the
captured state (Stage 1 complete, Stage 02 at standby, 4101 P) the conditions say
exactly six items are developable — Light Machine Gun MG1, Broad Sword SG1,
Dagger BG2, Pilum BP, Hound SMH, Dart 23 Rocket — and the blob's **six** `2`s sit
at WEAPONS-order indices **1, 5, 10, 12, 27, 31**, which are those six items, in
order. Nothing had to be fitted.
Indices 032 are the same item in either order, so this table stands unchanged
under the final mapping.
| blob | value | item | confirmed by |
|---|---|---|---|
| 0 | 4 | `No_Equipment` | always available |
| 1 | 2→4 | `Machiene_Cannon_MG1` | the differential: bought for 4000 P |
| 2 | 0→2 | `Machiene_Cannon_MG2H` | the differential: announced as newly developable |
| 5 | 2 | `Broad_Sword_SG1` | `BROAD SWORD SG1 5000P`, **below the fold** in the GUN list |
| 9 | 4 | `Stiletto_BG1` | `Developed`; "Initially Mounted"; mounted as NOSE WEAPON |
| 10 | 2 | `Dagger_BG2` | `DAGGER BG2 5000P` |
| 12 | 2 | `Pilum_BP` | `PILUM BP 6000P` |
| 21 | 4 | `Falcon_9AM` | `Developed`; "Initially Mounted"; mounted as MAIN WEAPON 1 |
| 22 | 4 | `Buzzard_10AM` | `Developed`; free at Stage 1 |
| 26 | 4 | `Terrier_SMH` | `Developed`; free at Stage 1 |
| 27 | 2 | `Hound_SMH` | `HOUND SMH 10000P` |
| 31 | 2 | `Dirt_23_Rocket` | `DART 23 ROCKET 3500P` ("Dart" is the localised "Dirt") |
Twelve concordances, no contradiction, over indices 031.
## The full index space ✅ — it is the **localisation** order, cut items included
Written after the probes below, which settled it. The blob is **not** indexed by
`weapon.tbl`'s `WEAPONS` id list. It is indexed by the order the items appear in
`strings.tbl` — the same order, *plus* the handful of items that exist only there:
`Adhesive Mine B2A` (a "Conditions to obtain" block whose cost is missing) and the
four late entries with a conditions *header* and no conditions. Those never appear
in the Arsenal, but they **hold slots in the save**, and that is the whole reason
the tail looked broken.
| blob | block | items (display names, in order) |
|---|---|---|
| 0 | — | `No_Equipment` |
| 18 | GUN | Light Machine Gun MG1, Light Swivel MG2H, Heavy MG3, Heavy Swivel MG5H, Broad Sword SG1, Swing Sword SG2H, Twin Sword SG3II, Flail GP37 |
| 916 | BEAM | **Stiletto BG1** ⌾, Dagger BG2, Rapier BG4H, Pilum BP, Multi Pike BP, Spear HBP, Long Spear HBP, Gray Head T76H |
| 1720 | LASER | Saber LG1, Twin Saber LG2H, Needle L1GP, Fire Arrow L2GP |
| 2125 | MPM | **Falcon 9AM** ⌾, Buzzard 10AM, Hawk 75AM, Condor 105AM, Eagle 120AM |
| 2630 | ASM | Terrier SMH, Hound SMH, Pointer SM, Piranha T53, White Shark T53R |
| 3138 | BOMB/ROCKET | **Dart 23 Rocket** ⌾, Arrow 27 Rocket, *Adhesive Mine B2A — never displayed*, EMP Mine B8E, Laser Mine B9L, Cauldron 50 Rocket, Cluster Mine B10, Maelstrom Bomb |
| 3947 | CANNON | **Tomahawk Alpha Rail Gun** ⌾, Sling 75KG, Glaive 120mm Cannon, Arbalest 155KG, Monoceros Long Cannon, Grav Cannon XGS, *+ three never-displayed slots* |
| 4853 | SPECIAL | **Jamming System** ⌾, Shield Doubler, Booster, Fire Control System, Regenerator, Cartridge Holder |
⌾ = pinned by an anchor: `9`, `21`, `39` and `48` were each **written into a save by
hand** and read back off the screen; `31` is the Dart 23 Rocket's price row in the
untouched save. The array ends exactly at 53 with the last SPECIAL item, with
nothing left over — 8+8+4+5+5+8+9+6 plus `No_Equipment` is 54.
The three never-displayed CANNON-block slots are three of `Ballista GSH`,
`Designator LH`, `Smoke Marker Launcher`, `Bumble UV` — the four cut entries
`strings.tbl` lists between Grav Cannon XGS and Jamming System. **Which three** is
🟡 undetermined and does not matter for reading a save; nothing displays them.
**This explains every anomaly in the retail save.** Its `4`s at 33 and at 45/46/47
are the cut items — `Adhesive Mine B2A` and three of the four above — shipped
flagged as owned. They never render, so no player ever sees them, and they are
exactly the five "unexplained" owned entries that made the tail look wrong. The
Tomahawk reading `Developed` at `blob[39]` while `weapon.tbl` puts it at 38 was
the same off-by-N, seen from the other side.
## How the tail was actually settled (and what was wrong first)
Past index 31 the `weapon.tbl` alignment fails, and it fails against the screen,
not against a theory — this is the evidence that sent the investigation to the
localisation order above:
- `Tomahawk_a_Rail_Gun` is WEAPONS index **38** and the CANNON list shows it
`Developed` (it is free once Stage 1 is complete) — but **blob[38] = 0**.
- The blob's remaining `4`s are at **33, 39, 45, 46, 47**. In WEAPONS order those
are `EMP_Mine_B8E`, `Sling_75KG`, `Shield_Doubler`, `Booster`,
`FireControlSystem` — and **every one of them is shown as not developed**:
BOMB/ROCKET lists only `DART 23 ROCKET 3500P`, CANNON only the Tomahawk, and
**the SPECIAL tab is entirely empty** (all rows dashed). Their conditions agree
— Shield Doubler needs Stage 12, Booster an ace kill in Stage 11, FCS Stage 6.
So five `4`s in the tail correspond to nothing the Arsenal reports as owned, and
one genuinely-owned item reads `0`. A single `+1` shift does not repair it
either: `blob[39] = 4` would put the Tomahawk right, but then `blob[33] = 4`
lands on `Arrow_27_Rocket` (Stage 2 + 5000 P, unreachable here) and 45/46/47
still land on locked SPECIAL items.
### Tested actively, with a hand-written save
Waiting for a mission payout was not necessary. The save container's derived
fields turned out to be reproducible (see
[`savegame_edit.py`](../../tools/re-capture/savegame_edit.py) — the whole
container re-wraps **byte-identically**), so the blob could be *written* and the
result read off the screen. A probe save was built in our own throwaway slot 03
with the blob zeroed except `0, 9, 21, 33, 38, 45 = 4` and `39, 46 = 2`, and it
**loaded**.
What it showed:
- **The controls rendered.** `9` → BEAM shows `STILETTO BG1 Developed`; `21`
MULTIPURPOSE MISSILE shows `FALCON 9AM Developed`. So a hand-written `4` does
reach the screen, and indices 9 and 21 are those items — now by experiment,
not by correlation.
- **The tail probes rendered nothing.** `33` should have made a BOMB/ROCKET row
`Developed` (row 3 of that list, on screen, not below the fold) — it stayed
dashed. `45` should have made a SPECIAL row `Developed` — the SPECIAL tab
stayed entirely empty. And `38` should have been the Tomahawk — instead the
CANNON list showed `TOMAHAWK ALPHA RAIL GUN — 0 P`, i.e. *not owned*.
- **Clearing the real save's tail `4`s cost the Tomahawk its status.** In the
unedited save the Tomahawk reads `Developed` while `blob[38] = 0`; the probe
cleared `{22, 26, 39, 46, 47}` and the Tomahawk stopped being owned — so its
flag is one of the cleared entries, with **39** the positional candidate. A
uniform `+1` shift past index 32 would explain that, but it fails elsewhere:
under it the real save's `45/46/47` are Chaff Flare Dispencer / Shield Doubler
/ Booster, and the SPECIAL tab is empty in that same save.
Three further probes settled it, and the reasoning is worth keeping because the
first read of the same evidence was wrong.
1. **Every slot owned.** With all 54 entries set to `4`, every tab filled and the
lists **wrap**, which gives exact counts: GUN 8, BEAM 8, LASER 4, MPM 5, ASM 5,
BOMB/ROCKET **7**, CANNON 6, SPECIAL 6 — 43 items — and the display order is
`weapon.tbl`'s order, item for item. Note what this probe *cannot* do: with
everything owned, any surjective mapping looks the same. It bounds, it does not
pin. It also killed the "gate-unmet flags are suppressed" idea outright —
Maelstrom Bomb (Stage 6) and Grav Cannon XGS (Stage 12) both rendered
`Developed` at a Stage-2 save.
2. **One slot owned: `39`.** `blob = 0` except `9` (control) and `39` → BEAM showed
`STILETTO BG1 Developed` **and CANNON showed `TOMAHAWK ALPHA RAIL GUN
Developed`**. So the CANNON block starts at 39, not 38: exactly one slot more
than the display between Dart 23 (31) and the Tomahawk.
3. **One slot owned: `48`.** `blob = 0` except `39` and `48` → SPECIAL showed
`JAMMING SYSTEM Developed`. That fixes the SPECIAL base, and with six SPECIAL
items the array closes exactly at 53.
The earlier probe's `45 = 4` leaving SPECIAL empty — which had looked like the
mapping failing — is simply a cut item: 45 is in the CANNON block's
never-displayed tail, not in SPECIAL at all.
### Two behaviours the probe exposed
- **`2` is derived, not stored.** On load the title recomputes which items are
developable from its own conditions and *announces the difference* — zeroing
Light Machine Gun MG1 and Broad Sword SG1 produced "You can now develop Broad
Sword …" and both came back priced in the list. Only the owned (`4`) entries
are authoritative state; writing `2` is pointless, and writing `0` over an
item whose conditions are met is undone at load.
- **The free items are bought for zero, not granted.** `TOMAHAWK ALPHA RAIL GUN
— 0 P` is what an unowned no-cost item looks like. That is the real reason a
save where the player never spent points still shows items as `Developed`
(this note previously said "granted" — the mechanism is a 0 P purchase).
## Evidence
- [`captures/arsenal-develop-conditions.csv`](captures/arsenal-develop-conditions.csv) — all 60 conditions blocks.
- [`captures/arsenal-categories-all.png`](captures/arsenal-categories-all.png) — the eight category tabs.
- [`captures/arsenal-gun-below-fold.png`](captures/arsenal-gun-below-fold.png) — Broad Sword SG1, the entry that is not visible without scrolling.
- [`captures/arsenal-probe-save-result.png`](captures/arsenal-probe-save-result.png) — the hand-written save's result: controls at 9/21 render `Developed`, the tail probes render nothing, and the Tomahawk shows `0 P`.
- [`captures/arsenal-gun-list-predevelop.png`](captures/arsenal-gun-list-predevelop.png) · [`captures/arsenal-mg1-developed.png`](captures/arsenal-mg1-developed.png) — the differential itself.

View File

@@ -0,0 +1,76 @@
XACH @0x8FBCBC 24 achievements (string table #5 of 7)
id 1 | bit 0 | 20G | Space Combat Award
unlocked: Received after your first space battle in the Glasner Training Area.
locked : Awarded for participating in fighter combat in outer space.
id 2 | bit 1 | 20G | Schlos Base Defense Award
unlocked: Received for stopping the missile attack on Schlos Base.
locked : Awarded for stopping enemy attacks on Schlos Base.
id 3 | bit 2 | 20G | Aegis of the People Medal
unlocked: Received for escorting all 7 refugee ships to safety.
locked : Awarded for escorting the 7 refugee ships to safety.
id 4 | bit 3 | 20G | TCAF Luna Medal
unlocked: Received for bravely helping the fleet escape during the invasion of the Matisse System.
locked : Awarded for bravery beyond the call of duty to escort allied vessels to safety.
id 5 | bit 4 | 40G | TCAF Mars Medal
unlocked: Received for bravery beyond the call of duty during the escape from the Matisse System.
locked : Awarded for bravery beyond the call of duty under fierce enemy attacks.
id 6 | bit 5 | 50G | Soldier's Charm Amulet
unlocked: Given to you by Raymond as a token of his trust.
locked : Given to you by Raymond as a token of his trust.
id 7 | bit 6 | 20G | White Griffons Patch
unlocked: Received by the commander and pilots of the White Griffon Squadron when it is formed.
locked : Awarded to the commander and pilots of the White Griffon Squadron when it is formed.
id 8 | bit 7 | 30G | TCAF Jupiter Medal
unlocked: Received for bravery during the enemy's attack on the Alberti System.
locked : Awarded for bravery beyond the call of duty during withdrawal of the allied fleet.
id 9 | bit 8 | 40G | Furious Pursuit Badge
unlocked: Received for continuing attacks on the enemy and shooting down a large number of ships.
locked : Awarded for diligently shooting down large numbers of enemy ships.
id 10 | bit 9 | 30G | Solo Aerospace Combat Award
unlocked: Received for descending into Acheron's atmosphere and engaging in combat alone.
locked : Awarded for descending into the atmosphere and engaging in combat alone.
id 11 | bit 10 | 30G | Operation Nebula Blaze Award
unlocked: Received for completing the extremely hazardous Operation Nebula Blaze.
locked : Awarded for fulfilling duty and fighting bravely in this difficult operation.
id 12 | bit 11 | 40G | Guilty Roses Patch
unlocked: Received for repelling the Guilty Roses Squadron.
locked : Awarded for repelling the enemy Guilty Roses Squadron.
id 13 | bit 12 | 30G | Super Battleship Slayer Patch
unlocked: Received for shooting down the second S battleship.
locked : Awarded for shooting down the second S battleship supporting the enemy fleet.
id 14 | bit 13 | 40G | TCAF Terra Medal
unlocked: Received for shielding the fleet and seeing that all ships safely fled the Ingres System.
locked : Awarded for shielding the fleet to allow its safe escape.
id 15 | bit 14 | 40G | Hellfires Patch
unlocked: Received for repelling the enemy Hellfire Squadron.
locked : Awarded for repelling the enemy Hellfire Squadron.
id 16 | bit 15 | 50G | Night Ravens Patch
unlocked: Received for challenging and eradicating the Night Raven Squadron.
locked : Awarded for challenging and eradicating the enemy Night Raven Squadron.
id 17 | bit 16 | 40G | Solar System Defense Award
unlocked: Received for great achievements during the campaign to defend the Solar System.
locked : Awarded for great achievement during the campaign to defend the Solar System.
id 18 | bit 17 | 40G | Special Operations Medal
unlocked: Received for heroically destroying the Prometheus Driver.
locked : Awarded for heroism in destroying the enemy's main weapon.
id 19 | bit 18 | 30G | 1,000 Units Destroyed Medal
unlocked: Received for shooting down 1,000 enemy fighters and attackers in combat.
locked : Awarded for shooting down 1,000 enemy fighters and attackers in space combat.
id 20 | bit 19 | 70G | 10,000 Units Destroyed Medal
unlocked: Received for shooting down 10,000 enemy fighters and attackers in combat.
locked : Awarded for shooting down 10,000 enemy fighters and attackers in space combat.
id 21 | bit 20 | 50G | Ship Hunter Award
unlocked: Received for shooting down 100 enemy warships in combat.
locked : Awarded for shooting down 100 warships in space combat.
id 22 | bit 21 | 70G | Gigaton Club Patch
unlocked: Received for shooting down enemy vessels with a total weight of one gigaton.
locked : Awarded for downing several enemy vessels with a combined weight of one gigaton.
id 23 | bit 22 | 80G | Weapon Lord Patch
unlocked: Received after you collect all usable equipment for the Delta Saber.
locked : Awarded for collecting all usable equipment for the Delta Saber.
id 24 | bit 23 | 100G | TCAF Pilot's Commendation
unlocked: Commemmorates you as one of the greatest pilots in history.
locked : Awarded to the greatest pilots in the TCAF.
total gamerscore = 1000 [OK: retail total]

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 KiB

View File

@@ -0,0 +1,61 @@
item,stage_required,points_cost,other_conditions,raw_text
Light Machine Gun MG1,1,4000,,Conditions to obtain Light Machine Gun MG1 - Complete Stage 1 - Must spend 4000 points to develop
Light Swivel Machine Gun MG2H,,3000,Completed development of the Light Machine Gun MG1,Conditions to obtain Light Swivel Machine Gun MG2H - Completed development of the Light Machine Gun MG1 - Must spend 3000 points to develop
Heavy Machine Gun MG3,6,6000,,Conditions to obtain Heavy Machine Gun MG3 - Complete Stage 6 - Must spend 6000 points to develop
Heavy Swivel Machine Gun MG5H,,10000,Completed development of the Heavy Machine Gun MG3,Conditions to obtain Heavy Swivel Machine Gun MG5H - Completed development of the Heavy Machine Gun MG3 - Must spend 10000 points to develop
Broad Sword SG1,1,5000,,Conditions to obtain Broad Sword SG1 - Complete Stage 1 - Must spend 5000 points to develop
Swing Sword SG2H,,10000,Completed development of the Twin Sword SG3II,Conditions to obtain Swing Sword SG2H - Completed development of the Twin Sword SG3II - Must spend 10000 points to develop
Twin Sword SG3II,,8000,Completed development of the Broad Sword SG1,Conditions to obtain Twin Sword SG3II - Completed development of the Broad Sword SG1 - Must spend 8000 points to develop
Flail GP37,6,,,Conditions to obtain Flail GP37 - Complete Stage 6
Stiletto BG1,,,Initially Mounted,Conditions to obtain Stiletto BG1 - Initially Mounted
Dagger BG2,,5000,Development Possible,Conditions to obtain Dagger BG2 - Development Possible - Must spend 5000 points to develop
Rapier BG4H,,12000,Completed development of the Dagger BG2,Conditions to obtain Rapier BG4H - Completed development of the Dagger BG2 - Must spend 12000 points to develop
Pilum BP,,6000,Development Possible,Conditions to obtain Pilum BP - Development Possible - Must spend 6000 points to develop
Multi Pike BP,,30000,Completed development of the Pilum BP,Conditions to obtain Multi Pike BP - Completed development of the Pilum BP - Must spend 30000 points to develop
Spear HBP,,10000,Completed development of the Multi Pike BP,Conditions to obtain Spear HBP - Completed development of the Multi Pike BP - Must spend 10000 points to develop
Long Spear HBP,7,350000,Completed development of the Gray Head T76H,Conditions to obtain Long Spear HBP - Complete Stage 7 - Completed development of the Gray Head T76H - Must spend 350000 points to develop
Gray Head T76H,,25000,Completed development of the Spear HBP,Conditions to obtain Gray Head T76H - Completed development of the Spear HBP - Must spend 25000 points to develop
Saber LG1,14,,,Conditions to obtain Saber LG1 - Complete Stage 14
Twin Saber LG2H,15,,,Conditions to obtain Twin Saber LG2H - Complete Stage 15
Needle L1GP,6,10000,,Conditions to obtain Needle L1GP - Complete Stage 6 - Must spend 10000 points to develop
Fire Arrow L2GP,,20000,Completed development of the Needle L1GP,Conditions to obtain Fire Arrow L2GP - Completed development of the Needle L1GP - Must spend 20000 points to develop
Falcon 9AM,,,Initially Mounted,Conditions to obtain Falcon 9AM - Initially Mounted
Buzzard 10AM,1,,,Conditions to obtain Buzzard 10AM - Complete Stage 1
Hawk 75AM,6,10000,Possess Buzzard 10AM,Conditions to obtain Hawk 75AM - Complete Stage 6 - Possess Buzzard 10AM - Must spend 10000 points to develop
Condor 105AM,6,,,Conditions to obtain Condor 105AM - Complete Stage 6
Eagle 120AM,12,15000,Completed development of the Hawk 75AM,Conditions to obtain Eagle 120AM - Complete Stage 12 - Completed development of the Hawk 75AM - Must spend 15000 points to develop
Terrier SMH,1,,,Conditions to obtain Terrier SMH - Complete Stage 1
Hound SMH,,10000,Development Possible,Conditions to obtain Hound SMH - Development Possible - Must spend 10000 points to develop
Pointer SM,,40000,Completed development of Piranha T53,Conditions to obtain Pointer SM - Completed development of Piranha T53 - Must spend 40000 points to develop
Piranha T53,,25000,Obtain White Shark T53R,Conditions to obtain Piranha T53 - Obtain White Shark T53R - Must spend 25000 points to develop
White Shark T53R,6,,,Conditions to obtain White Shark T53R - Complete Stage 6
Dart 23 Rocket,1,3500,,Conditions to obtain Dart 23 Rocket - Complete Stage 1 - Must spend 3500 points to develop
Arrow 27 Rocket,2,5000,,Conditions to obtain Arrow 27 Rocket - Complete Stage 2 - Must spend 5000 points to develop
Adhesive Mine B2A,3,MISSING,,Conditions to obtain Adhesive Mine B2A - Complete Stage 3 - Must spend points to develop
EMP Mine B8E,6,50000,,Conditions to obtain EMP Mine B8E - Complete Stage 6 - Must spend 50000 points to develop
Laser Mine B9L,6,15000,,Conditions to obtain Laser Mine B9L - Complete Stage 6 - Must spend 15000 points to develop
Cauldron 50 Rocket,14,,,Conditions to obtain Cauldron 50 Rocket - Complete Stage 14
Cluster Mine B10,6,5000,,Conditions to obtain Cluster Mine B10 - Complete Stage 6 - Must spend 5000 points to develop
Maelstrom Bomb,6,200000,,Conditions to obtain Maelstrom Bomb - Complete Stage 6 - Must spend 200000 points to develop
Tomahawk Alpha Rail Gun,1,,,Conditions to obtain Tomahawk Alpha Rail Gun - Complete Stage 1
Sling 75KG,2,5000,,Conditions to obtain Sling 75KG - Complete Stage 2 - Must spend 5000 points to develop
Glaive 120mm Cannon,6,,,Conditions to obtain Glaive 120mm Cannon - Complete Stage 6
Arbalest 155KG,6,25000,,Conditions to obtain Arbalest 155KG - Complete Stage 6 - Must spend 25000 points to develop
Monoceros Long Cannon,,,Shoot down the ace pilot in Stage 14,Conditions to obtain Monoceros Long Cannon - Shoot down the ace pilot in Stage 14
Grav Cannon XGS,12,60000,,Conditions to obtain Grav Cannon XGS - Complete Stage 12 - Must spend 60000 points to develop
Ballista GSH,,,,Conditions to obtain Ballista GSH
Designator LH,,,,Conditions to obtain Designator LH
Smoke Marker Launcher,,,,Conditions to obtain Smoke Marker Launcher
Bumble UV,,,,Conditions to obtain Bumble UV
Jamming System,3,15000,,Conditions to obtain Jamming System - Complete Stage 3 - Must spend 15000 points to develop
Shield Doubler,12,50000,,Conditions to obtain Shield Doubler - Complete Stage 12 - Must spend 50000 points to develop
Booster,,,Shoot down the ace pilot in Stage 11,Conditions to obtain Booster - Shoot down the ace pilot in Stage 11
Fire Control System,6,15000,,Conditions to obtain Fire Control System - Complete Stage 6 - Must spend 15000 points to develop
Regenerator,12,100000,,Conditions to obtain Regenerator - Complete Stage 12 - Must spend 100000 points to develop
Cartridge Holder,,,Shoot down the ace pilot in Stage 13,Conditions to obtain Cartridge Holder - Shoot down the ace pilot in Stage 13
Ballista 24 Rocket Launcher,,,,Conditions to obtain Ballista 24 Rocket Launcher
Thrush 220AM,,,,Conditions to obtain Thrush 220AM
Divider L3GP,,,,Conditions to obtain Divider L3GP
Mace GP25H,,,,Conditions to obtain Mace GP25H
Thor Gun System,,,,Conditions to obtain Thor Gun System
Spitfire BX,,,,Conditions to obtain Spitfire BX
1 item stage_required points_cost other_conditions raw_text
2 Light Machine Gun MG1 1 4000 Conditions to obtain Light Machine Gun MG1 - Complete Stage 1 - Must spend 4000 points to develop
3 Light Swivel Machine Gun MG2H 3000 Completed development of the Light Machine Gun MG1 Conditions to obtain Light Swivel Machine Gun MG2H - Completed development of the Light Machine Gun MG1 - Must spend 3000 points to develop
4 Heavy Machine Gun MG3 6 6000 Conditions to obtain Heavy Machine Gun MG3 - Complete Stage 6 - Must spend 6000 points to develop
5 Heavy Swivel Machine Gun MG5H 10000 Completed development of the Heavy Machine Gun MG3 Conditions to obtain Heavy Swivel Machine Gun MG5H - Completed development of the Heavy Machine Gun MG3 - Must spend 10000 points to develop
6 Broad Sword SG1 1 5000 Conditions to obtain Broad Sword SG1 - Complete Stage 1 - Must spend 5000 points to develop
7 Swing Sword SG2H 10000 Completed development of the Twin Sword SG3II Conditions to obtain Swing Sword SG2H - Completed development of the Twin Sword SG3II - Must spend 10000 points to develop
8 Twin Sword SG3II 8000 Completed development of the Broad Sword SG1 Conditions to obtain Twin Sword SG3II - Completed development of the Broad Sword SG1 - Must spend 8000 points to develop
9 Flail GP37 6 Conditions to obtain Flail GP37 - Complete Stage 6
10 Stiletto BG1 Initially Mounted Conditions to obtain Stiletto BG1 - Initially Mounted
11 Dagger BG2 5000 Development Possible Conditions to obtain Dagger BG2 - Development Possible - Must spend 5000 points to develop
12 Rapier BG4H 12000 Completed development of the Dagger BG2 Conditions to obtain Rapier BG4H - Completed development of the Dagger BG2 - Must spend 12000 points to develop
13 Pilum BP 6000 Development Possible Conditions to obtain Pilum BP - Development Possible - Must spend 6000 points to develop
14 Multi Pike BP 30000 Completed development of the Pilum BP Conditions to obtain Multi Pike BP - Completed development of the Pilum BP - Must spend 30000 points to develop
15 Spear HBP 10000 Completed development of the Multi Pike BP Conditions to obtain Spear HBP - Completed development of the Multi Pike BP - Must spend 10000 points to develop
16 Long Spear HBP 7 350000 Completed development of the Gray Head T76H Conditions to obtain Long Spear HBP - Complete Stage 7 - Completed development of the Gray Head T76H - Must spend 350000 points to develop
17 Gray Head T76H 25000 Completed development of the Spear HBP Conditions to obtain Gray Head T76H - Completed development of the Spear HBP - Must spend 25000 points to develop
18 Saber LG1 14 Conditions to obtain Saber LG1 - Complete Stage 14
19 Twin Saber LG2H 15 Conditions to obtain Twin Saber LG2H - Complete Stage 15
20 Needle L1GP 6 10000 Conditions to obtain Needle L1GP - Complete Stage 6 - Must spend 10000 points to develop
21 Fire Arrow L2GP 20000 Completed development of the Needle L1GP Conditions to obtain Fire Arrow L2GP - Completed development of the Needle L1GP - Must spend 20000 points to develop
22 Falcon 9AM Initially Mounted Conditions to obtain Falcon 9AM - Initially Mounted
23 Buzzard 10AM 1 Conditions to obtain Buzzard 10AM - Complete Stage 1
24 Hawk 75AM 6 10000 Possess Buzzard 10AM Conditions to obtain Hawk 75AM - Complete Stage 6 - Possess Buzzard 10AM - Must spend 10000 points to develop
25 Condor 105AM 6 Conditions to obtain Condor 105AM - Complete Stage 6
26 Eagle 120AM 12 15000 Completed development of the Hawk 75AM Conditions to obtain Eagle 120AM - Complete Stage 12 - Completed development of the Hawk 75AM - Must spend 15000 points to develop
27 Terrier SMH 1 Conditions to obtain Terrier SMH - Complete Stage 1
28 Hound SMH 10000 Development Possible Conditions to obtain Hound SMH - Development Possible - Must spend 10000 points to develop
29 Pointer SM 40000 Completed development of Piranha T53 Conditions to obtain Pointer SM - Completed development of Piranha T53 - Must spend 40000 points to develop
30 Piranha T53 25000 Obtain White Shark T53R Conditions to obtain Piranha T53 - Obtain White Shark T53R - Must spend 25000 points to develop
31 White Shark T53R 6 Conditions to obtain White Shark T53R - Complete Stage 6
32 Dart 23 Rocket 1 3500 Conditions to obtain Dart 23 Rocket - Complete Stage 1 - Must spend 3500 points to develop
33 Arrow 27 Rocket 2 5000 Conditions to obtain Arrow 27 Rocket - Complete Stage 2 - Must spend 5000 points to develop
34 Adhesive Mine B2A 3 MISSING Conditions to obtain Adhesive Mine B2A - Complete Stage 3 - Must spend points to develop
35 EMP Mine B8E 6 50000 Conditions to obtain EMP Mine B8E - Complete Stage 6 - Must spend 50000 points to develop
36 Laser Mine B9L 6 15000 Conditions to obtain Laser Mine B9L - Complete Stage 6 - Must spend 15000 points to develop
37 Cauldron 50 Rocket 14 Conditions to obtain Cauldron 50 Rocket - Complete Stage 14
38 Cluster Mine B10 6 5000 Conditions to obtain Cluster Mine B10 - Complete Stage 6 - Must spend 5000 points to develop
39 Maelstrom Bomb 6 200000 Conditions to obtain Maelstrom Bomb - Complete Stage 6 - Must spend 200000 points to develop
40 Tomahawk Alpha Rail Gun 1 Conditions to obtain Tomahawk Alpha Rail Gun - Complete Stage 1
41 Sling 75KG 2 5000 Conditions to obtain Sling 75KG - Complete Stage 2 - Must spend 5000 points to develop
42 Glaive 120mm Cannon 6 Conditions to obtain Glaive 120mm Cannon - Complete Stage 6
43 Arbalest 155KG 6 25000 Conditions to obtain Arbalest 155KG - Complete Stage 6 - Must spend 25000 points to develop
44 Monoceros Long Cannon Shoot down the ace pilot in Stage 14 Conditions to obtain Monoceros Long Cannon - Shoot down the ace pilot in Stage 14
45 Grav Cannon XGS 12 60000 Conditions to obtain Grav Cannon XGS - Complete Stage 12 - Must spend 60000 points to develop
46 Ballista GSH Conditions to obtain Ballista GSH
47 Designator LH Conditions to obtain Designator LH
48 Smoke Marker Launcher Conditions to obtain Smoke Marker Launcher
49 Bumble UV Conditions to obtain Bumble UV
50 Jamming System 3 15000 Conditions to obtain Jamming System - Complete Stage 3 - Must spend 15000 points to develop
51 Shield Doubler 12 50000 Conditions to obtain Shield Doubler - Complete Stage 12 - Must spend 50000 points to develop
52 Booster Shoot down the ace pilot in Stage 11 Conditions to obtain Booster - Shoot down the ace pilot in Stage 11
53 Fire Control System 6 15000 Conditions to obtain Fire Control System - Complete Stage 6 - Must spend 15000 points to develop
54 Regenerator 12 100000 Conditions to obtain Regenerator - Complete Stage 12 - Must spend 100000 points to develop
55 Cartridge Holder Shoot down the ace pilot in Stage 13 Conditions to obtain Cartridge Holder - Shoot down the ace pilot in Stage 13
56 Ballista 24 Rocket Launcher Conditions to obtain Ballista 24 Rocket Launcher
57 Thrush 220AM Conditions to obtain Thrush 220AM
58 Divider L3GP Conditions to obtain Divider L3GP
59 Mace GP25H Conditions to obtain Mace GP25H
60 Thor Gun System Conditions to obtain Thor Gun System
61 Spitfire BX Conditions to obtain Spitfire BX

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

View File

@@ -0,0 +1,50 @@
=== eng\prmain_scr.prt ===
23 elements
# element parent kind pivot kf placement
0 preff01.t32 - 0x0 (164,156) 5 rest (256,214) t=6..22 [6:256,214 22:256,214 31:256,214 42:256,214 1:256,214]
1 preff02.t32 - 0x0 (155,175) 7 rest (156,121) t=8..37 [4:305,240 6:193,150 7:165,128 8:156,121 37:155,120 44:155,120 2:305,240]
2 preff03.t32 - 0x0 (155,175) 7 rest (374,269) t=8..37 [4:225,150 6:337,240 7:365,262 8:374,269 37:375,270 44:375,270 3:225,150]
3 preff04.t32 - 0x0 (247,237) 6 rest (173,133) t=8..37 [4:173,133 6:173,133 8:173,133 37:173,133 44:173,133 4:173,133]
4 prwinbase.t32 - 0x0 (136,130) 5 rest (284,240) t=14..35 [6:284,240 14:284,240 35:284,240 43:284,240 5:284,240]
5 prbtn1.rat - 0x3002 (28,14) 5 rest (242,166) t=16..33 [10:242,166 16:242,166 33:242,166 37:242,166 6:242,166]
6 prbtn2.rat - 0x3002 (43,14) 5 rest (242,220) t=17..33 [11:242,220 17:242,220 33:242,220 37:242,220 7:242,220]
7 prbtn3.rat - 0x3002 (58,14) 5 rest (242,276) t=18..33 [12:242,276 18:242,276 33:242,276 37:242,276 8:242,276]
8 prbtn4.rat - 0x3002 (89,14) 5 rest (242,331) t=19..33 [13:242,331 19:242,331 33:242,331 37:242,331 9:242,331]
9 prbtn5.rat - 0x3002 (89,14) 5 rest (242,385) t=20..33 [14:242,385 20:242,385 33:242,385 37:242,385 10:242,385]
10 prbtn6.rat - 0x3002 (104,14) 5 rest (242,441) t=21..33 [15:242,441 21:242,441 33:242,441 37:242,441 11:242,441]
11 prbtn7.rat - 0x3002 (58,14) 5 rest (242,496) t=22..33 [16:242,496 22:242,496 33:242,496 37:242,496 12:242,496]
12 prbtn8.rat - 0x3002 (30,14) 5 rest (242,551) t=23..33 [17:242,551 23:242,551 33:242,551 37:242,551 13:242,551]
13 prexp1.t32 - 0x0 (277,30) 8 rest (726,143) t=31..37 [21:726,143 25:1286,143 27:866,143 28:756,143 29:731,143 31:726,143 37:726,143 14:1286,143]
14 prexp1a.t32 13 0x1 (277,30) 3 rest (1126,143) t=31..37 [31:1126,143 37:1126,143 15:1126,143]
15 prexp3.t32 - 0x0 (205,5) 5 rest (726,381) t=10..29 [10:726,381 29:726,381 31:726,381 39:726,381 16:726,381]
16 prmsg.t32 - 0x0 (330,19) 5 rest (151,645) t=21..29 [21:151,645 29:151,645 31:151,645 37:151,645 17:151,645]
17 prexp3.t32 - 0x4 (205,5) 5 rest (726,415) t=10..29 [10:726,415 29:726,415 31:726,415 39:726,415 18:726,415]
18 prexp3.t32 - 0x4 (205,5) 5 rest (726,449) t=10..29 [10:726,449 29:726,449 31:726,449 39:726,449 19:726,449]
19 prexp3.t32 - 0x4 (205,5) 5 rest (726,483) t=10..29 [10:726,483 29:726,483 31:726,483 39:726,483 20:726,483]
20 prexp3.t32 - 0x4 (205,5) 5 rest (726,517) t=10..29 [10:726,517 29:726,517 31:726,517 39:726,517 21:726,517]
21 prexp3.t32 - 0x4 (205,5) 5 rest (726,551) t=10..29 [10:726,551 29:726,551 31:726,551 39:726,551 22:726,551]
22 prexp3.t32 - 0x4 (205,5) 5 rest (726,585) t=39..1869640736 [10:726,585 29:726,585 31:726,585 39:726,585 1869640736:726,585]
=== eng\prselect_scr.prt ===
8 elements
# element parent kind pivot kf placement
0 prselect_win1.t32 - 0x0 (256,200) 10 rest (127,155) t=8..23 [4:-516,155 6:-71,155 7:81,155 8:127,155 23:134,155 24:134,155 25:127,155 27:81,155 31:-71,155 1:-516,155]
1 prselect_win2.t32 0 0x1 (256,200) 1 (135,560)
2 prselect_win1eff.t32 0 0x1 (256,200) 5 rest (71,92) t=2..14 [2:71,92 14:71,92 21:71,92 27:71,92 3:71,92]
3 prselect_win2eff.t32 0 0x1 (256,200) 5 rest (71,499) t=2..14 [2:71,499 14:71,499 21:71,499 27:71,499 4:71,499]
4 prselect_sbar.sbo 0 0x1 (256,200) 1 (614,172)
5 prselect_type_line.t32 - 0x0 (260,24) 5 rest (123,142) t=14..21 [8:123,142 14:123,142 21:123,142 25:123,142 6:123,142]
6 prselect_msg.t32 - 0x0 (468,19) 5 rest (151,645) t=11..19 [11:151,645 19:151,645 21:151,645 27:151,645 7:151,645]
7 prselect_msgbtn1.t32 - 0x2 (128,19) 5 rest (809,645) t=27..1869640736 [11:809,645 19:809,645 21:809,645 27:809,645 1869640736:809,645]
=== eng\prselect_win3.prt ===
2 elements
# element parent kind pivot kf placement
0 prselect_win3.t32 - 0x0 (245,300) 4 rest (704,39) t=10..16 [8:704,39 10:704,39 16:704,39 1:704,39]
1 prselect_win3eff.t32 - 0x0 (309,352) 4 rest (640,0) t=16..1869640736 [8:640,0 10:640,0 16:640,0 1869640736:640,0]
=== eng\prtitle.prt ===
5 elements
# element parent kind pivot kf placement
0 preff11.t32 - 0x0 (221,6) 6 rest (-160,82) t=16..35 [12:-440,82 14:-440,82 16:-160,82 35:0,82 41:0,82 1:-440,82]
1 preff12.t32 - 0x0 (75,3) 5 rest (0,72) t=20..34 [14:0,72 20:0,72 34:0,72 38:0,72 2:0,72]
2 preff13.t32 - 0x0 (53,3) 5 rest (407,78) t=22..33 [16:407,78 22:407,78 33:407,78 37:407,78 3:407,78]
3 preff14.t32 - 0x0 (52,3) 5 rest (427,68) t=24..32 [18:427,68 24:427,68 32:427,68 36:427,68 4:427,68]
4 prtitle.t32 - 0x0 (94,35) 7 rest (162,26) t=37..1869640736 [20:142,26 23:142,26 25:157,26 26:161,26 31:162,26 37:162,26 1869640736:142,26]

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

View File

@@ -0,0 +1,5 @@
input,roll_deg_s,yaw_deg_s,pitch_deg_s
rx+,0.0,0.0,0.0
ry+,0.0,0.0,0.0
LB,0.0,0.0,0.0
RB,0.0,0.0,0.0
1 input roll_deg_s yaw_deg_s pitch_deg_s
2 rx+ 0.0 0.0 0.0
3 ry+ 0.0 0.0 0.0
4 LB 0.0 0.0 0.0
5 RB 0.0 0.0 0.0

View File

@@ -0,0 +1,7 @@
input,roll_deg_s,yaw_deg_s,pitch_deg_s
lx+,209.8,0.87,10.07
ly+,0.0,154.12,0.0
rx+,0.0,0.0,0.0
ry+,161.07,86.98,37.06
LB,0.0,0.0,0.0
RB,0.0,0.0,0.0
1 input roll_deg_s yaw_deg_s pitch_deg_s
2 lx+ 209.8 0.87 10.07
3 ly+ 0.0 154.12 0.0
4 rx+ 0.0 0.0 0.0
5 ry+ 161.07 86.98 37.06
6 LB 0.0 0.0 0.0
7 RB 0.0 0.0 0.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 KiB

View File

@@ -0,0 +1,96 @@
=== 1. StageResource records in GP_MAIN_GAME_E.pak ===
29 stage records
S01 bg=Lebendorf tokens=52
S02 bg=Lebendorf tokens=51
S03 bg=Planet_Lebendorf tokens=51
S04 bg=Lebendorf_far tokens=54
S05 bg=Lebendorf_far tokens=54
S06 bg=Hargenteen tokens=51
S07 bg=Hargenteen tokens=53
S08 bg=Barch tokens=59
S09 bg=Acheron tokens=53
S10 bg=Acheron tokens=53
S11 bg=Anastasis tokens=53
S12 bg=Hargenteen tokens=53
S13 bg=Hargenteen tokens=56
S14 bg=Earth tokens=59
S15 bg=Earth tokens=53
S16 bg=PD tokens=53
S18 bg=Original tokens=43
S19 bg=Original tokens=43
S20 bg=Original tokens=43
S21 bg=Original tokens=43
S22 bg=Original tokens=50
S23 bg=Original tokens=43
S24 bg=Anastasis tokens=53
S25 bg=Hargenteen tokens=53
S26 bg=Hargenteen tokens=53
S27 bg=Planet_Lebendorf tokens=53
S28 bg=Lebendorf tokens=54
S29 bg=Earth tokens=53
Test bg=Earth tokens=43
=== 2. GP_CHALLENGE.pak contents ===
151 entries, 0 IDXD objects
=== 3. tokens mentioning Challenge / EX across the main pak ===
033b5b7e pgmsg_challenge1.t32
033b5b7e pgmsg_challenge2.t32
033b5b7e pgmsg_challenge3.t32
033b5b7e pgmsg_challenge4.t32
033b5b7e pgmsg_challenge5.t32
033b5b7e pgmsg_challenge6.t32
35b8dc67 UN_e001_ADAN_Elan_EX4
35b8dc67 UN_e007_ADAN_Turret_EX4
35b8dc67 UN_e008_ADAN_TurretPlus_EX4
35b8dc67 UN_e010_ADAN_Attacker_S_EX4
35b8dc67 UN_e011_ADAN_Attacker_B_EX4
35b8dc67 UN_e107_ADAN_AAFrigate_EX4
35b8dc67 UN_f001_TCAF_DeltaSaber_T_EX5
35b8dc67 UN_f001_TCAF_DeltaSaber_T_EX5_el
35b8dc67 UN_f003_TCAF_ArrowHead_EX4
35b8dc67 UN_f003_TCAF_ArrowHead_EX5
35b8dc67 UN_f104_TCAF_Battleship_EX5
35b8dc67 UN_f105_TCAF_Cruiser_EX5
3c5b0549 UN_e107_ADAN_AAFrigate_EX4
3c5b0549 UN_f104_TCAF_Battleship_EX5
3c5b0549 UN_f105_TCAF_Cruiser_EX5
3c5b0549 UnitName_UN_e107_ADAN_AAFrigate_EX4
3c5b0549 UnitName_UN_f104_TCAF_Battleship_EX5
3c5b0549 UnitName_UN_f105_TCAF_Cruiser_EX5
3c5b0549 Weapon_TCAF_Ship_AAGun_EX5
3c9ae32e EnumWeapon_EX5.tbl
43faa517 UN_e001_ADAN_Elan_EX4
43faa517 UN_e007_ADAN_Turret_EX4
43faa517 UN_e008_ADAN_TurretPlus_EX4
43faa517 UN_e010_ADAN_Attacker_S_EX4
43faa517 UN_e011_ADAN_Attacker_B_EX4
43faa517 UN_f001_TCAF_DeltaSaber_T_EX5
43faa517 UN_f001_TCAF_DeltaSaber_T_EX5_el
43faa517 UN_f003_TCAF_ArrowHead_EX4
43faa517 UN_f003_TCAF_ArrowHead_EX5
43faa517 UnitName_UN_e001_ADAN_Elan_EX4
43faa517 UnitName_UN_e007_ADAN_Turret_EX4
43faa517 UnitName_UN_e008_ADAN_TurretPlus_EX4
43faa517 UnitName_UN_e010_ADAN_Attacker_S_EX4
43faa517 UnitName_UN_e011_ADAN_Attacker_B_EX4
43faa517 UnitName_UN_f001_TCAF_DeltaSaber_T_EX5
43faa517 UnitName_UN_f001_TCAF_DeltaSaber_T_EX5_el
43faa517 UnitName_UN_f003_TCAF_ArrowHead_EX4
43faa517 UnitName_UN_f003_TCAF_ArrowHead_EX5
659aff47 UN_e001_ADAN_Elan_EX4
659aff47 UN_e007_ADAN_Turret_EX4
659aff47 UN_e008_ADAN_TurretPlus_EX4
659aff47 UN_e010_ADAN_Attacker_S_EX4
659aff47 UN_e011_ADAN_Attacker_B_EX4
659aff47 UN_e107_ADAN_AAFrigate_EX4
659aff47 UN_f003_TCAF_ArrowHead_EX4
6ab4825a WeaponCannonName_TCAF_Ship_AAGun_EX5
6ab4825a Weapon_TCAF_Ship_AAGun_EX5
6b9b000d UN_f001_TCAF_DeltaSaber_T_EX5
6b9b000d UN_f001_TCAF_DeltaSaber_T_EX5_el
6b9b000d UN_f003_TCAF_ArrowHead_EX5
6b9b000d UN_f104_TCAF_Battleship_EX5
6b9b000d UN_f105_TCAF_Cruiser_EX5
ffbc19c2 Weapon_TCAF_Ship_AAGun_EX5
(59 distinct)

View File

@@ -0,0 +1,750 @@
===== tables.pak entry #60 schema 54a10697 123 tokens [dat\GP_CHALLENGE.pak+fra\] =====
0 xBASE_INFO
1 0x061031
2 VERSION
3 dat\GP_CHALLENGE.pak+fra\
4 PATH
5 strings.tbl
6 STRINGS
7 py_menu_scr.prt
8 MENU
9 CHIPS
10 py_menu_n_btn1.prt
11 Button_TimeAttack
12 py_menu_n_btn2.prt
13 Button_ScoreAttack
14 py_menu_n_btn3.prt
15 Button_Extra01
16 py_menu_n_btn4.prt
17 Button_Extra02
18 py_menu_n_btn5.prt
19 Button_Extra03
20 py_menu_n_btn6.prt
21 Button_Extra04
22 py_menu_b_btn1.prt
23 Button_TimeAttack_Gray
24 py_menu_b_btn2.prt
25 Button_ScoreAttack_Gray
26 py_menu_b_btn3.prt
27 Button_Extra01_Gray
28 py_menu_b_btn4.prt
29 Button_Extra02_Gray
30 py_menu_b_btn5.prt
31 Button_Extra03_Gray
32 py_menu_b_btn6.prt
33 Button_Extra04_Gray
34 py_menu_challenge1.t32
35 Thumbnail_TimeAttack
36 py_menu_challenge2.t32
37 Thumbnail_ScoreAttack
38 py_menu_challenge3.t32
39 Thumbnail_Extra01
40 py_menu_challenge4.t32
41 Thumbnail_Extra02
42 py_menu_challenge5.t32
43 Thumbnail_Extra03
44 py_menu_challenge6.t32
45 Thumbnail_Extra04
46 py_menu_str_time.t32
47 Record_Time
48 py_menu_str_point.t32
49 Record_Point
50 py_menu_str_p.t32
51 Unit_Point
52 py_menu_new.prt
53 New_Stage
54 FONTS
55 Font_Desc
56 Font_Record
57 CONTM___.TTF
58 FONT
59 24
60 HEIGHT
61 28
62 Desc_Stage
63 580,400
64 LOCATE
65 8
66 LINE_SPACE
67 Desc_Time
68 1048,608,R
69 0
70 INDEX
71 Desc_Points
72 1032,608,R
73 UNIT
74 New_Stage01
75 New_Stage02
76 0,60
77 INDEX_ADJUST
78 New_Stage03
79 0,120
80 New_Stage04
81 0,180
82 New_Stage05
83 0,240
84 New_Stage06
85 0,300
86 MISSIONS
87 TimeAttack
88 ScoreAttack
89 Extra01
90 Extra02
91 Extra03
92 Extra04
93 MISSION_ID
94 Time
95 RECORD_TYPE
96 16
97 REQUIREMENT
98 NORMAL_BUTTON
99 GRAY_BUTTON
100 THUMBNAIL
101 TimeAttackRequirement
102 REQUIREMENT_DESC
103 TimeAttackStageDesc
104 STAGE_DESC
105 TEXT_STAGE
106 TEXT_RECORD
107 NEW_STAGE
108 25
109 Points
110 ScoreAttackRequirement
111 ScoreAttackStageDesc
112 26
113 Extra01Requirement
114 ExtraStage01Desc
115 27
116 Extra02Requirement
117 ExtraStage02Desc
118 Extra03Requirement
119 ExtraStage03Desc
120 29
121 Extra04Requirement
122 ExtraStage04Desc
===== tables.pak entry #64 schema 54a10697 123 tokens [dat\GP_CHALLENGE.pak+eng\] =====
0 xBASE_INFO
1 0x061031
2 VERSION
3 dat\GP_CHALLENGE.pak+eng\
4 PATH
5 strings.tbl
6 STRINGS
7 py_menu_scr.prt
8 MENU
9 CHIPS
10 py_menu_n_btn1.prt
11 Button_TimeAttack
12 py_menu_n_btn2.prt
13 Button_ScoreAttack
14 py_menu_n_btn3.prt
15 Button_Extra01
16 py_menu_n_btn4.prt
17 Button_Extra02
18 py_menu_n_btn5.prt
19 Button_Extra03
20 py_menu_n_btn6.prt
21 Button_Extra04
22 py_menu_b_btn1.prt
23 Button_TimeAttack_Gray
24 py_menu_b_btn2.prt
25 Button_ScoreAttack_Gray
26 py_menu_b_btn3.prt
27 Button_Extra01_Gray
28 py_menu_b_btn4.prt
29 Button_Extra02_Gray
30 py_menu_b_btn5.prt
31 Button_Extra03_Gray
32 py_menu_b_btn6.prt
33 Button_Extra04_Gray
34 py_menu_challenge1.t32
35 Thumbnail_TimeAttack
36 py_menu_challenge2.t32
37 Thumbnail_ScoreAttack
38 py_menu_challenge3.t32
39 Thumbnail_Extra01
40 py_menu_challenge4.t32
41 Thumbnail_Extra02
42 py_menu_challenge5.t32
43 Thumbnail_Extra03
44 py_menu_challenge6.t32
45 Thumbnail_Extra04
46 py_menu_str_time.t32
47 Record_Time
48 py_menu_str_point.t32
49 Record_Point
50 py_menu_str_p.t32
51 Unit_Point
52 py_menu_new.prt
53 New_Stage
54 FONTS
55 Font_Desc
56 Font_Record
57 CONTM___.TTF
58 FONT
59 24
60 HEIGHT
61 28
62 Desc_Stage
63 580,400
64 LOCATE
65 8
66 LINE_SPACE
67 Desc_Time
68 1048,608,R
69 0
70 INDEX
71 Desc_Points
72 1032,608,R
73 UNIT
74 New_Stage01
75 New_Stage02
76 0,60
77 INDEX_ADJUST
78 New_Stage03
79 0,120
80 New_Stage04
81 0,180
82 New_Stage05
83 0,240
84 New_Stage06
85 0,300
86 MISSIONS
87 TimeAttack
88 ScoreAttack
89 Extra01
90 Extra02
91 Extra03
92 Extra04
93 MISSION_ID
94 Time
95 RECORD_TYPE
96 16
97 REQUIREMENT
98 NORMAL_BUTTON
99 GRAY_BUTTON
100 THUMBNAIL
101 TimeAttackRequirement
102 REQUIREMENT_DESC
103 TimeAttackStageDesc
104 STAGE_DESC
105 TEXT_STAGE
106 TEXT_RECORD
107 NEW_STAGE
108 25
109 Points
110 ScoreAttackRequirement
111 ScoreAttackStageDesc
112 26
113 Extra01Requirement
114 ExtraStage01Desc
115 27
116 Extra02Requirement
117 ExtraStage02Desc
118 Extra03Requirement
119 ExtraStage03Desc
120 29
121 Extra04Requirement
122 ExtraStage04Desc
===== tables.pak entry #67 schema 54a10697 123 tokens [dat\GP_CHALLENGE.pak+deu\] =====
0 xBASE_INFO
1 0x061031
2 VERSION
3 dat\GP_CHALLENGE.pak+deu\
4 PATH
5 strings.tbl
6 STRINGS
7 py_menu_scr.prt
8 MENU
9 CHIPS
10 py_menu_n_btn1.prt
11 Button_TimeAttack
12 py_menu_n_btn2.prt
13 Button_ScoreAttack
14 py_menu_n_btn3.prt
15 Button_Extra01
16 py_menu_n_btn4.prt
17 Button_Extra02
18 py_menu_n_btn5.prt
19 Button_Extra03
20 py_menu_n_btn6.prt
21 Button_Extra04
22 py_menu_b_btn1.prt
23 Button_TimeAttack_Gray
24 py_menu_b_btn2.prt
25 Button_ScoreAttack_Gray
26 py_menu_b_btn3.prt
27 Button_Extra01_Gray
28 py_menu_b_btn4.prt
29 Button_Extra02_Gray
30 py_menu_b_btn5.prt
31 Button_Extra03_Gray
32 py_menu_b_btn6.prt
33 Button_Extra04_Gray
34 py_menu_challenge1.t32
35 Thumbnail_TimeAttack
36 py_menu_challenge2.t32
37 Thumbnail_ScoreAttack
38 py_menu_challenge3.t32
39 Thumbnail_Extra01
40 py_menu_challenge4.t32
41 Thumbnail_Extra02
42 py_menu_challenge5.t32
43 Thumbnail_Extra03
44 py_menu_challenge6.t32
45 Thumbnail_Extra04
46 py_menu_str_time.t32
47 Record_Time
48 py_menu_str_point.t32
49 Record_Point
50 py_menu_str_p.t32
51 Unit_Point
52 py_menu_new.prt
53 New_Stage
54 FONTS
55 Font_Desc
56 Font_Record
57 CONTM___.TTF
58 FONT
59 24
60 HEIGHT
61 28
62 Desc_Stage
63 580,400
64 LOCATE
65 8
66 LINE_SPACE
67 Desc_Time
68 1048,608,R
69 0
70 INDEX
71 Desc_Points
72 1032,608,R
73 UNIT
74 New_Stage01
75 New_Stage02
76 0,60
77 INDEX_ADJUST
78 New_Stage03
79 0,120
80 New_Stage04
81 0,180
82 New_Stage05
83 0,240
84 New_Stage06
85 0,300
86 MISSIONS
87 TimeAttack
88 ScoreAttack
89 Extra01
90 Extra02
91 Extra03
92 Extra04
93 MISSION_ID
94 Time
95 RECORD_TYPE
96 16
97 REQUIREMENT
98 NORMAL_BUTTON
99 GRAY_BUTTON
100 THUMBNAIL
101 TimeAttackRequirement
102 REQUIREMENT_DESC
103 TimeAttackStageDesc
104 STAGE_DESC
105 TEXT_STAGE
106 TEXT_RECORD
107 NEW_STAGE
108 25
109 Points
110 ScoreAttackRequirement
111 ScoreAttackStageDesc
112 26
113 Extra01Requirement
114 ExtraStage01Desc
115 27
116 Extra02Requirement
117 ExtraStage02Desc
118 Extra03Requirement
119 ExtraStage03Desc
120 29
121 Extra04Requirement
122 ExtraStage04Desc
===== tables.pak entry #68 schema 54a10697 123 tokens [dat\GP_CHALLENGE.pak+ita\] =====
0 xBASE_INFO
1 0x061031
2 VERSION
3 dat\GP_CHALLENGE.pak+ita\
4 PATH
5 strings.tbl
6 STRINGS
7 py_menu_scr.prt
8 MENU
9 CHIPS
10 py_menu_n_btn1.prt
11 Button_TimeAttack
12 py_menu_n_btn2.prt
13 Button_ScoreAttack
14 py_menu_n_btn3.prt
15 Button_Extra01
16 py_menu_n_btn4.prt
17 Button_Extra02
18 py_menu_n_btn5.prt
19 Button_Extra03
20 py_menu_n_btn6.prt
21 Button_Extra04
22 py_menu_b_btn1.prt
23 Button_TimeAttack_Gray
24 py_menu_b_btn2.prt
25 Button_ScoreAttack_Gray
26 py_menu_b_btn3.prt
27 Button_Extra01_Gray
28 py_menu_b_btn4.prt
29 Button_Extra02_Gray
30 py_menu_b_btn5.prt
31 Button_Extra03_Gray
32 py_menu_b_btn6.prt
33 Button_Extra04_Gray
34 py_menu_challenge1.t32
35 Thumbnail_TimeAttack
36 py_menu_challenge2.t32
37 Thumbnail_ScoreAttack
38 py_menu_challenge3.t32
39 Thumbnail_Extra01
40 py_menu_challenge4.t32
41 Thumbnail_Extra02
42 py_menu_challenge5.t32
43 Thumbnail_Extra03
44 py_menu_challenge6.t32
45 Thumbnail_Extra04
46 py_menu_str_time.t32
47 Record_Time
48 py_menu_str_point.t32
49 Record_Point
50 py_menu_str_p.t32
51 Unit_Point
52 py_menu_new.prt
53 New_Stage
54 FONTS
55 Font_Desc
56 Font_Record
57 CONTM___.TTF
58 FONT
59 24
60 HEIGHT
61 28
62 Desc_Stage
63 580,400
64 LOCATE
65 8
66 LINE_SPACE
67 Desc_Time
68 1048,608,R
69 0
70 INDEX
71 Desc_Points
72 1032,608,R
73 UNIT
74 New_Stage01
75 New_Stage02
76 0,60
77 INDEX_ADJUST
78 New_Stage03
79 0,120
80 New_Stage04
81 0,180
82 New_Stage05
83 0,240
84 New_Stage06
85 0,300
86 MISSIONS
87 TimeAttack
88 ScoreAttack
89 Extra01
90 Extra02
91 Extra03
92 Extra04
93 MISSION_ID
94 Time
95 RECORD_TYPE
96 16
97 REQUIREMENT
98 NORMAL_BUTTON
99 GRAY_BUTTON
100 THUMBNAIL
101 TimeAttackRequirement
102 REQUIREMENT_DESC
103 TimeAttackStageDesc
104 STAGE_DESC
105 TEXT_STAGE
106 TEXT_RECORD
107 NEW_STAGE
108 25
109 Points
110 ScoreAttackRequirement
111 ScoreAttackStageDesc
112 26
113 Extra01Requirement
114 ExtraStage01Desc
115 27
116 Extra02Requirement
117 ExtraStage02Desc
118 Extra03Requirement
119 ExtraStage03Desc
120 29
121 Extra04Requirement
122 ExtraStage04Desc
===== tables.pak entry #74 schema 54a10697 123 tokens [dat\GP_CHALLENGE.pak+jpn\] =====
0 BASE_INFO
1 0x061031
2 VERSION
3 dat\GP_CHALLENGE.pak+jpn\
4 PATH
5 strings.tbl
6 STRINGS
7 py_menu_scr.prt
8 MENU
9 CHIPS
10 py_menu_n_btn1.prt
11 Button_TimeAttack
12 py_menu_n_btn2.prt
13 Button_ScoreAttack
14 py_menu_n_btn3.prt
15 Button_Extra01
16 py_menu_n_btn4.prt
17 Button_Extra02
18 py_menu_n_btn5.prt
19 Button_Extra03
20 py_menu_n_btn6.prt
21 Button_Extra04
22 py_menu_b_btn1.prt
23 Button_TimeAttack_Gray
24 py_menu_b_btn2.prt
25 Button_ScoreAttack_Gray
26 py_menu_b_btn3.prt
27 Button_Extra01_Gray
28 py_menu_b_btn4.prt
29 Button_Extra02_Gray
30 py_menu_b_btn5.prt
31 Button_Extra03_Gray
32 py_menu_b_btn6.prt
33 Button_Extra04_Gray
34 py_menu_challenge1.t32
35 Thumbnail_TimeAttack
36 py_menu_challenge2.t32
37 Thumbnail_ScoreAttack
38 py_menu_challenge3.t32
39 Thumbnail_Extra01
40 py_menu_challenge4.t32
41 Thumbnail_Extra02
42 py_menu_challenge5.t32
43 Thumbnail_Extra03
44 py_menu_challenge6.t32
45 Thumbnail_Extra04
46 py_menu_str_time.t32
47 Record_Time
48 py_menu_str_point.t32
49 Record_Point
50 py_menu_str_p.t32
51 Unit_Point
52 py_menu_new.prt
53 New_Stage
54 FONTS
55 Font_Desc
56 Font_Record
57 DFSOGE5.TTC
58 FONT
59 24
60 HEIGHT
61 CONTM___.TTF
62 28
63 Desc_Stage
64 580,400
65 LOCATE
66 8
67 LINE_SPACE
68 Desc_Time
69 1048,608,R
70 INDEX
71 Desc_Points
72 1032,608,R
73 UNIT
74 New_Stage01
75 New_Stage02
76 0,60
77 INDEX_ADJUST
78 New_Stage03
79 0,120
80 New_Stage04
81 0,180
82 New_Stage05
83 0,240
84 New_Stage06
85 0,300
86 MISSIONS
87 TimeAttack
88 ScoreAttack
89 Extra01
90 Extra02
91 Extra03
92 Extra04
93 MISSION_ID
94 Time
95 RECORD_TYPE
96 16
97 REQUIREMENT
98 NORMAL_BUTTON
99 GRAY_BUTTON
100 THUMBNAIL
101 TimeAttackRequirement
102 REQUIREMENT_DESC
103 TimeAttackStageDesc
104 STAGE_DESC
105 TEXT_STAGE
106 TEXT_RECORD
107 NEW_STAGE
108 25
109 Points
110 ScoreAttackRequirement
111 ScoreAttackStageDesc
112 26
113 Extra01Requirement
114 ExtraStage01Desc
115 27
116 Extra02Requirement
117 ExtraStage02Desc
118 Extra03Requirement
119 ExtraStage03Desc
120 29
121 Extra04Requirement
122 ExtraStage04Desc
===== tables.pak entry #75 schema 54a10697 123 tokens [dat\GP_CHALLENGE.pak+esp\] =====
0 xBASE_INFO
1 0x061031
2 VERSION
3 dat\GP_CHALLENGE.pak+esp\
4 PATH
5 strings.tbl
6 STRINGS
7 py_menu_scr.prt
8 MENU
9 CHIPS
10 py_menu_n_btn1.prt
11 Button_TimeAttack
12 py_menu_n_btn2.prt
13 Button_ScoreAttack
14 py_menu_n_btn3.prt
15 Button_Extra01
16 py_menu_n_btn4.prt
17 Button_Extra02
18 py_menu_n_btn5.prt
19 Button_Extra03
20 py_menu_n_btn6.prt
21 Button_Extra04
22 py_menu_b_btn1.prt
23 Button_TimeAttack_Gray
24 py_menu_b_btn2.prt
25 Button_ScoreAttack_Gray
26 py_menu_b_btn3.prt
27 Button_Extra01_Gray
28 py_menu_b_btn4.prt
29 Button_Extra02_Gray
30 py_menu_b_btn5.prt
31 Button_Extra03_Gray
32 py_menu_b_btn6.prt
33 Button_Extra04_Gray
34 py_menu_challenge1.t32
35 Thumbnail_TimeAttack
36 py_menu_challenge2.t32
37 Thumbnail_ScoreAttack
38 py_menu_challenge3.t32
39 Thumbnail_Extra01
40 py_menu_challenge4.t32
41 Thumbnail_Extra02
42 py_menu_challenge5.t32
43 Thumbnail_Extra03
44 py_menu_challenge6.t32
45 Thumbnail_Extra04
46 py_menu_str_time.t32
47 Record_Time
48 py_menu_str_point.t32
49 Record_Point
50 py_menu_str_p.t32
51 Unit_Point
52 py_menu_new.prt
53 New_Stage
54 FONTS
55 Font_Desc
56 Font_Record
57 CONTM___.TTF
58 FONT
59 24
60 HEIGHT
61 28
62 Desc_Stage
63 580,400
64 LOCATE
65 8
66 LINE_SPACE
67 Desc_Time
68 1048,608,R
69 0
70 INDEX
71 Desc_Points
72 1032,608,R
73 UNIT
74 New_Stage01
75 New_Stage02
76 0,60
77 INDEX_ADJUST
78 New_Stage03
79 0,120
80 New_Stage04
81 0,180
82 New_Stage05
83 0,240
84 New_Stage06
85 0,300
86 MISSIONS
87 TimeAttack
88 ScoreAttack
89 Extra01
90 Extra02
91 Extra03
92 Extra04
93 MISSION_ID
94 Time
95 RECORD_TYPE
96 16
97 REQUIREMENT
98 NORMAL_BUTTON
99 GRAY_BUTTON
100 THUMBNAIL
101 TimeAttackRequirement
102 REQUIREMENT_DESC
103 TimeAttackStageDesc
104 STAGE_DESC
105 TEXT_STAGE
106 TEXT_RECORD
107 NEW_STAGE
108 25
109 Points
110 ScoreAttackRequirement
111 ScoreAttackStageDesc
112 26
113 Extra01Requirement
114 ExtraStage01Desc
115 27
116 Extra02Requirement
117 ExtraStage02Desc
118 Extra03Requirement
119 ExtraStage03Desc
120 29
121 Extra04Requirement
122 ExtraStage04Desc

View File

@@ -0,0 +1,115 @@
id hp size_x size_y size_z kind
UN_e006_ADAN_Vindicator_MargrasF 6400 12 5 30 unit
UN_be005_ADAN_SpaceFortress 10000 2500 3100 2600 unit
UN_f001_TCAF_DeltaSaber_T 1000 10 7 29 unit
UN_f002_TCAF_DeltaSaber_W 1000 10 7 29 unit
UN_e009_ADAN_Phantom 1000 18 16 24 unit
UN_e001_ADAN_Elan_EX4 500 25 10 30 unit
UN_e011_ADAN_Attacker_B 1500 200 50 100 unit
UN_f001_TCAF_DeltaSaber_T_Ttrl 1000 10 7 29 unit
UN_f202_TCAF_Cargo 4000 1400 600 2000 unit
UN_e007_ADAN_Turret_EX4 100 20 9 22 unit
UN_e010_ADAN_Attacker_S 500 100 40 50 unit
UN_be001_ADAN_TerrafoamingUnit 10000 2600 2200 — unit
UN_e005_ADAN_ElanTypeQ_Margras 3200 24 15 38 unit
UN_e001_ADAN_Elan_GR_Violeta 1200 25 10 30 unit
UN_e004_ADAN_ElanPlus_N 800 24 10 30 unit
UN_e201_ADAN_ISCMissile 3000 300 — 2800 unit
UN_f001_TCAF_DeltaSaber_T_Player_Ttrl1 1500 10 7 29 unit
UN_f001_TCAF_DeltaSaber_T_Player_Ttrl2 1500 10 7 29 unit
UN_f003_TCAF_ArrowHead_EX4 300 10 5 20 unit
UN_f003_TCAF_ArrowHead_EX5 300 10 5 20 unit
UN_e011_ADAN_Attacker_B_HF 2250 200 50 100 unit
UN_e015_ADAN_Puppy — 7 5.5 10 unit
Ship_Test 100 20 14 29 unit
UN_e010_ADAN_Attacker_S_HF 750 100 40 50 unit
Test_ADAN_PrometheusDriver 10000 1 — — unit
UN_f001_TCAF_DeltaSaber_T_EX5 1000 10 7 29 unit
UN_e002_ADAN_Elan_N 800 20 10 30 unit
UN_e008_ADAN_TurretPlus 300 24 9 35 unit
UN_mn040_Asteroid_Big 10000 2000 3500 — unit
UN_mn500_ADAN_FloatingMine 1000 200 — — unit
UN_S04_Asteroid_cmesh_01 10000 46900 55500 13600 unit
UN_S05_Asteroid_cmesh_01 10000 10400 13000 34400 unit
UN_S04_Asteroid_cmesh_03 10000 42800 37700 47400 unit
UN_S04_Asteroid_cmesh_04 10000 59000 56900 21800 unit
UN_S05_Asteroid_cmesh_03 10000 8300 13000 19800 unit
UN_S04_Asteroid_cmesh_05 — 26000 21800 72400 unit
UN_S05_Asteroid_cmesh_04 10000 7400 15800 30100 unit
UN_e011_ADAN_Attacker_B_EX4 1500 200 50 100 unit
UN_e910_core_ADAN_GeneratorCore 10000 2000 — — unit
UN_n001_TTRL_Box 100 0.1 — — unit
UN_e006_ADAN_Vindicator_Margras 6400 12 5 30 unit
UN_e010_ADAN_Attacker_S_EX4 500 100 40 50 unit
UN_bf001_TCAF_SchlosBase 10000 2500 3100 2600 unit
UN_f001_TCAF_DeltaSaber_T_EX5_el 1000 10 7 29 unit
UN_n001_TTRL_Box_move 100 0.1 — — unit
UN_e004_ADAN_ElanPlus_NF 800 24 10 30 unit
Test_ADAN_PrometheusDriver_InsideP2_01 10000 1 — — unit
UN_e001_ADAN_Elan 500 25 10 30 unit
UN_e007_ADAN_Turret 100 20 9 22 unit
UN_e003_ADAN_ElanPlus_Margras 1600 24 10 30 unit
UN_e901_ADAN_Boss 10000 500 1700 — unit
UN_e013_ADAN_ElanPlus_Taskent 1300 24 10 30 unit
UN_e011_ADAN_Attacker_B_HF_Wayne 3000 200 50 100 unit
UN_f003_TCAF_ArrowHead 300 10 5 20 unit
UN_f004_TCAF_DeltaSaber_A_Player 1500 10 7 29 unit
UN_e010_ADAN_Attacker_S_HF_Wayne 1000 100 40 50 unit
UN_e015_ADAN_Puppy_2 — 7 5.5 10 unit
UN_S01_Asteroid_cmesh_01a — 23000 21700 13400 unit
UN_S01_Asteroid_cmesh_02a 10000 20900 24000 23800 unit
UN_S01_Asteroid_cmesh_01b — 18000 20900 14200 unit
UN_S01_Asteroid_cmesh_02b — 20400 21800 26700 unit
UN_S01_Asteroid_cmesh_03a 10000 23600 11700 30300 unit
UN_bf002_TCAF_TransitPlatform 10000 2500 3100 4600 unit
UN_e001_ADAN_Elan_GR 800 25 10 30 unit
UN_S13_Asteroid_cmesh_01a 10000 14200 10200 14700 unit
UN_S01_Asteroid_cmesh_03b — 21800 18600 37200 unit
UN_S01_Asteroid_cmesh_04a — 32800 19900 23300 unit
UN_S13_Asteroid_cmesh_01b 10000 8000 5400 16400 unit
UN_S13_Asteroid_cmesh_02a 10000 6200 5400 11300 unit
UN_S01_Asteroid_cmesh_04b — 26400 23100 19600 unit
UN_S04_Asteroid_cmesh_02a — 25100 20800 61400 unit
UN_S13_Asteroid_cmesh_02b 10000 5100 6900 10500 unit
UN_S05_Asteroid_cmesh_02a 10000 10100 13200 9100 unit
UN_S04_Asteroid_cmesh_02b 10000 25200 19700 67000 unit
UN_S05_Asteroid_cmesh_02b 10000 8700 14900 12300 unit
UN_S08_Asteroid_cmesh_01a 10000 14700 14900 21300 unit
UN_S08_Asteroid_cmesh_01b 10000 14900 14600 15800 unit
UN_S08_Asteroid_cmesh_02a 10000 17400 13400 15200 unit
UN_S04_Asteroid_cmesh_06a — 14400 20800 45900 unit
UN_S08_Asteroid_cmesh_02b 10000 16800 10700 14200 unit
UN_S08_Asteroid_cmesh_03a — 14000 10000 13700 unit
UN_S04_Asteroid_cmesh_06b 10000 14100 16200 21500 unit
UN_S04_Asteroid_cmesh_07a — 23200 14000 67100 unit
UN_S08_Asteroid_cmesh_03b 10000 13900 15100 15700 unit
UN_S04_Asteroid_cmesh_07b 10000 25300 4900 40600 unit
UN_e008_ADAN_TurretPlus_EX4 300 24 9 35 unit
UN_f001_TCAF_DeltaSaber_T_Player 1500 10 7 29 unit
UN_f003_TCAF_ArrowHead_Yoji 400 10 5 20 unit
UN_f002_TCAF_DeltaSaber_W_Player 1500 10 7 29 unit
UN_e104_ADAN_Carrier 20000 2300 1000 3300 vessel
UN_f106_TCAF_Destroyer 10000 200 — 2000 vessel
UN_e105_ADAN_Cruiser 30000 600 — 3800 vessel
UN_e101_ADAN_SDBattleship 100000 1900 2100 12300 vessel
UN_f105_TCAF_Cruiser 30000 700 — 3800 vessel
UN_f102_TCAF_LightCarrier 20000 500 — 1600 vessel
UN_e102_ADAN_BattleshipEX 50000 1900 2300 6500 vessel
UN_e107_ADAN_AAFrigate_EX4 4000 100 200 — vessel
UN_e102_ADAN_Battleship 50000 1900 2300 6500 vessel
UN_f104_TCAF_Battleship 30000 2000 — 6000 vessel
UN_f105_TCAF_Cruiser_EX5 30000 700 — 3800 vessel
UN_e108_ADAN_ASFrigateEX 4000 80 — 350 vessel
UN_f106_TCAF_Destroyer_Inv 10000 200 — 2000 vessel
UN_e107_ADAN_AAFrigate 4000 100 200 — vessel
UN_f104_TCAF_Battleship_EX5 30000 2000 — 6000 vessel
UN_f105_TCAF_Cruiser_Inv 30000 700 — 3800 vessel
UN_f102_TCAF_LightCarrier_Inv 20000 500 — 1600 vessel
UN_e108_ADAN_ASFrigate 4000 80 — 350 vessel
UN_e106_ADAN_DestroyerEX 10000 300 — 2100 vessel
UN_f101_TCAF_Acropolis 25000 400 — 1400 vessel
UN_e105_ADAN_CruiserEX 30000 600 — 3800 vessel
UN_e101_ADAN_SDBattleshipEX 100000 1900 2100 12300 vessel
UN_e106_ADAN_Destroyer 10000 300 — 2100 vessel

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,161 @@
run,turret_rule,t_s,acropolis_hull,e007_alive,e007_killed_cum
mission01,off,0,25000.0,0,0
mission01,off,10,25000.0,5,0
mission01,off,20,25000.0,6,0
mission01,off,30,25000.0,6,0
mission01,off,40,25000.0,6,0
mission01,off,50,25000.0,8,0
mission01,off,60,25000.0,11,0
mission01,off,70,25000.0,11,3
mission01,off,80,25000.0,8,3
mission01,off,90,25000.0,8,3
mission01,off,100,25000.0,10,3
mission01,off,110,25000.0,14,3
mission01,off,120,25000.0,16,3
mission01,off,130,25000.0,17,3
mission01,off,140,25000.0,20,3
mission01,off,150,25000.0,23,3
mission01,off,160,25000.0,26,4
mission01,off,170,24509.5,33,4
mission01,off,180,24509.5,37,4
mission01,off,190,24419.5,38,5
mission01,off,200,24212.5,41,5
mission01,off,210,23447.5,40,5
mission01,off,220,23447.5,40,5
mission01,off,220,23447.5,40,5
mission01,off,230,23447.5,41,5
mission01,off,230,23447.5,41,5
mission01,off,240,22912.0,43,5
mission01,off,240,22867.0,43,5
mission01,off,250,22667.0,42,6
mission01,off,250,22647.0,42,6
mission01,off,260,22334.5,44,6
mission01,off,260,22177.9,44,6
mission01,off,270,21101.2,46,6
mission01,off,270,21101.2,45,6
mission01,off,280,20292.6,46,6
mission01,off,280,20279.1,46,6
mission01,off,290,19598.5,58,6
mission01,off,290,19598.5,58,6
mission01,off,300,18725.2,58,6
mission01,off,300,18658.5,47,6
mission01,off,310,18231.9,47,6
mission01,off,310,18231.9,47,6
mission01,off,320,17425.2,47,6
mission01,off,330,17046.9,49,6
mission01,off,340,16044.8,49,7
mission01,off,350,15461.5,50,7
mission01,off,360,14956.3,51,7
mission01,off,370,14425.5,51,8
mission01,off,380,13910.5,51,8
mission01,off,390,13418.9,49,11
mission01,off,400,13218.9,45,12
mission01,off,410,12517.9,44,12
mission01,off,420,12161.3,44,13
mission01,off,430,11601.3,44,13
mission02,on,0,25000.0,0,0
mission02,on,10,25000.0,5,0
mission02,on,20,25000.0,6,0
mission02,on,30,25000.0,5,0
mission02,on,40,25000.0,5,1
mission02,on,50,25000.0,7,1
mission02,on,60,25000.0,10,1
mission02,on,70,25000.0,7,4
mission02,on,80,25000.0,7,4
mission02,on,90,25000.0,7,4
mission02,on,100,25000.0,7,4
mission02,on,110,25000.0,9,4
mission02,on,120,25000.0,14,4
mission02,on,130,25000.0,16,4
mission02,on,140,25000.0,19,4
mission02,on,150,25000.0,19,4
mission02,on,160,25000.0,23,5
mission02,on,170,24730.0,28,5
mission02,on,180,24730.0,32,5
mission02,on,190,24730.0,36,5
mission02,on,200,24640.0,43,6
mission02,on,210,24190.0,46,6
mission02,on,220,24055.0,47,6
mission02,on,220,24055.0,47,6
mission02,on,230,24055.0,49,6
mission02,on,230,24055.0,49,6
mission02,on,240,23474.5,52,6
mission02,on,240,23474.5,52,6
mission02,on,250,23474.5,53,6
mission02,on,250,23474.5,53,6
mission02,on,260,23474.5,54,6
mission02,on,260,23474.5,54,6
mission02,on,270,22697.0,54,6
mission02,on,270,22697.0,54,6
mission02,on,280,22091.2,56,6
mission02,on,280,22091.2,55,7
mission02,on,290,21577.9,55,7
mission02,on,290,21555.4,55,7
mission02,on,300,20799.1,56,7
mission02,on,310,19781.1,55,7
mission02,on,320,18925.8,56,8
mission02,on,330,18592.5,56,8
mission02,on,340,17432.5,57,8
mission02,on,350,16932.6,59,8
mission02,on,360,16171.6,58,9
mission02,on,370,15017.6,58,11
mission02,on,380,14217.2,59,11
mission02,on,390,13322.2,59,12
mission02,on,400,12808.9,58,13
mission02,on,410,12090.6,61,16
mission02,on,420,11440.6,66,19
mission02,on,430,11127.3,55,19
mission03,on,0,25000.0,0,0
mission03,on,10,25000.0,3,0
mission03,on,20,25000.0,8,0
mission03,on,30,25000.0,5,0
mission03,on,40,25000.0,6,0
mission03,on,50,25000.0,8,0
mission03,on,60,25000.0,9,0
mission03,on,70,25000.0,16,0
mission03,on,80,25000.0,10,0
mission03,on,90,25000.0,11,0
mission03,on,100,25000.0,10,2
mission03,on,110,25000.0,15,2
mission03,on,120,25000.0,12,2
mission03,on,130,25000.0,10,4
mission03,on,140,25000.0,11,6
mission03,on,150,25000.0,14,6
mission03,on,160,25000.0,17,7
mission03,on,170,24685.0,24,7
mission03,on,180,24640.0,23,7
mission03,on,190,24640.0,27,7
mission03,on,200,24460.0,26,8
mission03,on,210,24100.0,28,8
mission03,on,220,24100.0,29,9
mission03,on,230,24100.0,28,11
mission03,on,230,24100.0,28,11
mission03,on,240,23810.0,35,11
mission03,on,240,23810.0,36,11
mission03,on,250,23630.0,36,12
mission03,on,250,23630.0,36,12
mission03,on,260,23630.0,38,13
mission03,on,260,23630.0,38,13
mission03,on,270,22998.0,39,13
mission03,on,270,22908.0,39,13
mission03,on,280,22197.5,40,13
mission03,on,280,22188.5,40,13
mission03,on,290,21602.4,38,13
mission03,on,290,21602.4,38,13
mission03,on,300,21042.4,39,13
mission03,on,300,20975.8,39,13
mission03,on,310,20529.1,41,15
mission03,on,310,20529.1,41,15
mission03,on,320,19812.5,38,17
mission03,on,320,19767.5,38,17
mission03,on,330,19434.2,39,18
mission03,on,340,18785.9,37,19
mission03,on,350,18339.2,37,19
mission03,on,360,17528.1,38,19
mission03,on,370,16901.5,37,19
mission03,on,380,16260.7,35,19
mission03,on,390,15708.2,37,19
mission03,on,400,15346.2,37,19
mission03,on,410,14412.1,36,20
mission03,on,420,13951.9,37,22
mission03,on,430,13211.9,35,23
1 run turret_rule t_s acropolis_hull e007_alive e007_killed_cum
2 mission01 off 0 25000.0 0 0
3 mission01 off 10 25000.0 5 0
4 mission01 off 20 25000.0 6 0
5 mission01 off 30 25000.0 6 0
6 mission01 off 40 25000.0 6 0
7 mission01 off 50 25000.0 8 0
8 mission01 off 60 25000.0 11 0
9 mission01 off 70 25000.0 11 3
10 mission01 off 80 25000.0 8 3
11 mission01 off 90 25000.0 8 3
12 mission01 off 100 25000.0 10 3
13 mission01 off 110 25000.0 14 3
14 mission01 off 120 25000.0 16 3
15 mission01 off 130 25000.0 17 3
16 mission01 off 140 25000.0 20 3
17 mission01 off 150 25000.0 23 3
18 mission01 off 160 25000.0 26 4
19 mission01 off 170 24509.5 33 4
20 mission01 off 180 24509.5 37 4
21 mission01 off 190 24419.5 38 5
22 mission01 off 200 24212.5 41 5
23 mission01 off 210 23447.5 40 5
24 mission01 off 220 23447.5 40 5
25 mission01 off 220 23447.5 40 5
26 mission01 off 230 23447.5 41 5
27 mission01 off 230 23447.5 41 5
28 mission01 off 240 22912.0 43 5
29 mission01 off 240 22867.0 43 5
30 mission01 off 250 22667.0 42 6
31 mission01 off 250 22647.0 42 6
32 mission01 off 260 22334.5 44 6
33 mission01 off 260 22177.9 44 6
34 mission01 off 270 21101.2 46 6
35 mission01 off 270 21101.2 45 6
36 mission01 off 280 20292.6 46 6
37 mission01 off 280 20279.1 46 6
38 mission01 off 290 19598.5 58 6
39 mission01 off 290 19598.5 58 6
40 mission01 off 300 18725.2 58 6
41 mission01 off 300 18658.5 47 6
42 mission01 off 310 18231.9 47 6
43 mission01 off 310 18231.9 47 6
44 mission01 off 320 17425.2 47 6
45 mission01 off 330 17046.9 49 6
46 mission01 off 340 16044.8 49 7
47 mission01 off 350 15461.5 50 7
48 mission01 off 360 14956.3 51 7
49 mission01 off 370 14425.5 51 8
50 mission01 off 380 13910.5 51 8
51 mission01 off 390 13418.9 49 11
52 mission01 off 400 13218.9 45 12
53 mission01 off 410 12517.9 44 12
54 mission01 off 420 12161.3 44 13
55 mission01 off 430 11601.3 44 13
56 mission02 on 0 25000.0 0 0
57 mission02 on 10 25000.0 5 0
58 mission02 on 20 25000.0 6 0
59 mission02 on 30 25000.0 5 0
60 mission02 on 40 25000.0 5 1
61 mission02 on 50 25000.0 7 1
62 mission02 on 60 25000.0 10 1
63 mission02 on 70 25000.0 7 4
64 mission02 on 80 25000.0 7 4
65 mission02 on 90 25000.0 7 4
66 mission02 on 100 25000.0 7 4
67 mission02 on 110 25000.0 9 4
68 mission02 on 120 25000.0 14 4
69 mission02 on 130 25000.0 16 4
70 mission02 on 140 25000.0 19 4
71 mission02 on 150 25000.0 19 4
72 mission02 on 160 25000.0 23 5
73 mission02 on 170 24730.0 28 5
74 mission02 on 180 24730.0 32 5
75 mission02 on 190 24730.0 36 5
76 mission02 on 200 24640.0 43 6
77 mission02 on 210 24190.0 46 6
78 mission02 on 220 24055.0 47 6
79 mission02 on 220 24055.0 47 6
80 mission02 on 230 24055.0 49 6
81 mission02 on 230 24055.0 49 6
82 mission02 on 240 23474.5 52 6
83 mission02 on 240 23474.5 52 6
84 mission02 on 250 23474.5 53 6
85 mission02 on 250 23474.5 53 6
86 mission02 on 260 23474.5 54 6
87 mission02 on 260 23474.5 54 6
88 mission02 on 270 22697.0 54 6
89 mission02 on 270 22697.0 54 6
90 mission02 on 280 22091.2 56 6
91 mission02 on 280 22091.2 55 7
92 mission02 on 290 21577.9 55 7
93 mission02 on 290 21555.4 55 7
94 mission02 on 300 20799.1 56 7
95 mission02 on 310 19781.1 55 7
96 mission02 on 320 18925.8 56 8
97 mission02 on 330 18592.5 56 8
98 mission02 on 340 17432.5 57 8
99 mission02 on 350 16932.6 59 8
100 mission02 on 360 16171.6 58 9
101 mission02 on 370 15017.6 58 11
102 mission02 on 380 14217.2 59 11
103 mission02 on 390 13322.2 59 12
104 mission02 on 400 12808.9 58 13
105 mission02 on 410 12090.6 61 16
106 mission02 on 420 11440.6 66 19
107 mission02 on 430 11127.3 55 19
108 mission03 on 0 25000.0 0 0
109 mission03 on 10 25000.0 3 0
110 mission03 on 20 25000.0 8 0
111 mission03 on 30 25000.0 5 0
112 mission03 on 40 25000.0 6 0
113 mission03 on 50 25000.0 8 0
114 mission03 on 60 25000.0 9 0
115 mission03 on 70 25000.0 16 0
116 mission03 on 80 25000.0 10 0
117 mission03 on 90 25000.0 11 0
118 mission03 on 100 25000.0 10 2
119 mission03 on 110 25000.0 15 2
120 mission03 on 120 25000.0 12 2
121 mission03 on 130 25000.0 10 4
122 mission03 on 140 25000.0 11 6
123 mission03 on 150 25000.0 14 6
124 mission03 on 160 25000.0 17 7
125 mission03 on 170 24685.0 24 7
126 mission03 on 180 24640.0 23 7
127 mission03 on 190 24640.0 27 7
128 mission03 on 200 24460.0 26 8
129 mission03 on 210 24100.0 28 8
130 mission03 on 220 24100.0 29 9
131 mission03 on 230 24100.0 28 11
132 mission03 on 230 24100.0 28 11
133 mission03 on 240 23810.0 35 11
134 mission03 on 240 23810.0 36 11
135 mission03 on 250 23630.0 36 12
136 mission03 on 250 23630.0 36 12
137 mission03 on 260 23630.0 38 13
138 mission03 on 260 23630.0 38 13
139 mission03 on 270 22998.0 39 13
140 mission03 on 270 22908.0 39 13
141 mission03 on 280 22197.5 40 13
142 mission03 on 280 22188.5 40 13
143 mission03 on 290 21602.4 38 13
144 mission03 on 290 21602.4 38 13
145 mission03 on 300 21042.4 39 13
146 mission03 on 300 20975.8 39 13
147 mission03 on 310 20529.1 41 15
148 mission03 on 310 20529.1 41 15
149 mission03 on 320 19812.5 38 17
150 mission03 on 320 19767.5 38 17
151 mission03 on 330 19434.2 39 18
152 mission03 on 340 18785.9 37 19
153 mission03 on 350 18339.2 37 19
154 mission03 on 360 17528.1 38 19
155 mission03 on 370 16901.5 37 19
156 mission03 on 380 16260.7 35 19
157 mission03 on 390 15708.2 37 19
158 mission03 on 400 15346.2 37 19
159 mission03 on 410 14412.1 36 20
160 mission03 on 420 13951.9 37 22
161 mission03 on 430 13211.9 35 23

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

Some files were not shown because too many files have changed in this diff Show More