Compare commits
10 Commits
formats-pi
...
recover/op
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e57eda14e4 | ||
|
|
b305aa4a5a | ||
|
|
108308057a | ||
|
|
620ec5e60b | ||
|
|
de5f04038d | ||
|
|
06f890361b | ||
|
|
a71dea9b8d | ||
|
|
d394c55cbb | ||
|
|
18b5b3d5f1 | ||
|
|
4ac23b94dd |
@@ -15,7 +15,7 @@
|
||||
"boot": [
|
||||
{
|
||||
"screen": "publisher_logo",
|
||||
"why": "The SQUARE ENIX wordmark is the first thing the boot shows -- RE agent, 2026-08-29. Entry 10 of the pair; 13 is its region twin and the port shows one, not both."
|
||||
"why": "The SQUARE ENIX wordmark is the first thing the boot shows -- RE agent, 2026-08-29. Entry 10 of the pair; 13 is its region twin and the port shows one, not both. \ud83d\udccc SOURCE, added 2026-09-01: the boot's screen order and dwells are derived from GP_TITLE's own entries -- see docs/port/FORMAT.md for the export shape and docs/re/ui-title-build-map.md for which entry is which screen. The order here is not authored; it is what the archive declares."
|
||||
},
|
||||
{
|
||||
"screen": "developer_logos",
|
||||
@@ -25,31 +25,651 @@
|
||||
"video": "ADV",
|
||||
"why": "HANDOFF Q9, DECODED from the movie manifest: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the attract movie are the SAME asset -- there is no separate boot slot. Its POSITION here (after the developer logos, before the title) is measured, not decoded: it is the order the RE agent watched the game boot in.",
|
||||
"skippable": true,
|
||||
"skippable_why": "HANDOFF Q9: one (A) press skips a movie -- measured, title reached at 57 s against a 193 s baseline."
|
||||
"skippable_why": "HANDOFF Q9: one (A) press skips a movie -- measured, title reached at 57 s against a 193 s baseline.",
|
||||
"skippable_kind": "measured"
|
||||
},
|
||||
{
|
||||
"screen": "title",
|
||||
"why": "HANDOFF Q2/Q6: the boot reaches the title after the intro movie. The port holds here -- nothing takes the title's place until P5 gives it somewhere to go."
|
||||
"overlay": {
|
||||
"screen": "press_start",
|
||||
"clock": "shared",
|
||||
"why": "MEASURED, 2026-08-29, docs/re/title-plate-delay-measured.md on branch auto/no-disc-and-menu-captures at 5b0a6e6 (NOT on main when this was written). The boot title shows build 4 ALONE and the `PRESS (A) BUTTON` plate -- build 2 -- arrives later. This is the ONE case in the port where two builds are drawn at once.",
|
||||
"no_constant_why": "THERE IS NO AUTHORED DELAY HERE, AND THERE WAS ONE FOR ONE ITERATION. The first version of this block carried `after_settle_seconds: 2.13`, taken from the RE agent's instruction. The port refuted that instruction with arithmetic off the disc -- build 2 has a group of its own, and starting it at settle put the plate 3.97 s late -- and the corrected answer needs no constant at all: BOTH BUILDS RUN ON ONE CLOCK, STARTED TOGETHER, and the plate arrives at its own declared t=236 (CORRECTED 2026-09-01 from t=238, which is the last opaque frame rather than the arrival). `clock: \"shared\"` is that, spelled out rather than implied by the absence of a delay field. \ud83d\udccc SOURCES, added 2026-09-01 in the uncited-why backfill: the plate's arrival is docs/re/title-plate-delay-measured.md and its pulse is docs/re/structures/plate-pulse-measured.md. \ud83d\udd34 AND `clock: \"shared\"` IS AUTHORED FROM OUR OWN ARITHMETIC, NOT MEASURED. Nobody has watched whether build 2's group starts with build 4's; it is the reading that reconciles the oracle's 2.13 s IF the settle anchor is t=118. See docs/port/plate-arrival-halves.md and BLOCKED.md H3.",
|
||||
"arithmetic_why": "Why one clock reproduces the measurement, checked against this export rather than taken on trust: build 4's effect quads `pteff01`, `pteff02` and `ptlogoall_eff` end their ramps together at t=118; `ptbtn00` reaches alpha 255 at t=236; the difference is 118 units = 1.967 s at 60 units/s. The oracle measured 2.138 s and 2.132 s. The gap is presentation rate: the emulator presents at 28.1 fps against a nominal 30, and the corpus had independently measured the idle title at 28.5 fps before these runs. \ud83d\udd34 CORRECTED 2026-09-01: this said `ptbtn00` reaches 255 at t=238 and that the difference is 120 units = 2.000 s. It reaches 255 at t=236 and HOLDS to 238, so 238 is the last opaque frame, not the arrival; 236 - 118 = 118. The port printed the contradiction in one sentence on every boot. The correction moves the reconciliation by 0.033 s and overturns nothing -- see docs/port/plate-arrival-halves.md. \ud83d\udd34 AND THE ANCHOR IS NOW OPEN. The oracle defines \"title settled\" operationally, as its glyph counter first reading the no-plate value 154. This export offers TWO anchors 42 units apart: t=118 (the effect quads) and t=160 (`ptcopyright` at full alpha -- the LAST element to finish building in, and the only one made of glyphs). This line picked 118, while `ScreenView.settle_time()` returns 160 and the boot prints `settles at t=160`, so one binary holds both. Asked in BLOCKED.md H3; not guessed here. \ud83d\udccc SOURCE: the pulse period and its phase behaviour are in docs/re/structures/plate-pulse-measured.md and docs/re/structures/plate-pulse-phase-lock.md, with the raw series in docs/re/data/plate-pulse-timeseries.txt. \u2705 AUDITED 2026-09-01: the corpus's 28.5 fps is a genuinely independent leg -- a different quantity (idle-title presentation rate), measured BEFORE these runs, so it could have come out disagreeing. It agrees to 1.4 %.",
|
||||
"the_premise_that_failed_why": "The port's own, and it is worth keeping because it will bite again: `rest.t` IS NOT WHEN A SCREEN SETTLES. It is the last hold keyframe before the exit. Reading it as the settle put build 4's arrival at 4.35 s instead of 1.97 s, and every reconciliation computed from it came out wrong by exactly that error. `ScreenView.settle_time()` still uses rest.t -- see docs/port/BLOCKED.md. \ud83d\udd34 THE EXAMPLE THIS CITED IS GONE, THOUGH THE CONCLUSION IS NOT. It read \"`ptlogo1` has rest.t=251 and stops MOVING at t=42\". In the CURRENT export `ptlogo1.rest.t` is 42 -- equal to when it stops moving. The record-layout fix repaired precisely that element, and the entry was never re-derived under it (REFUTED.md now carries this at \ud83d\udfe1 \u27e8our-reader\u27e9). rest.t is still wrong for transients -- `ptlogo_back2eff1` is a two-frame flash whose rest.t=54 is the flash PEAK -- and for `pteff00`, whose rest.t=16 sits at the end of the fade-FROM-black while a fade-TO-black runs 261..269. Re-derived 2026-09-01: docs/port/plate-arrival-halves.md. \ud83d\udd34 AND IT IS NOT THIS DEFECT'S CAUSE. The plate's ARRIVAL is a declared keyframe (transparent to t=214, opaque at t=236), not a rest pose; rest.t=236 only chooses where `holding` parks it, and 236 is that ramp's own peak. Confirmed on a filmed boot with rest.t untouched: the onset is bracketed within one frame of 214.",
|
||||
"scope_why": "Attached to the BOOT STEP, not to the `title` screen, and that is deliberate. What was measured is the boot title. Whether the title shows the plate when it is REACHED AGAIN -- by (B) from the main menu, or after the attract movie -- is not measured, and putting the overlay on the screen would quietly claim it is. \ud83d\udccc SOURCE, added 2026-09-01: the plate belongs to the boot's overlay step rather than to the title screen because its arrival is measured against the boot clock -- docs/re/title-plate-delay-measured.md. \ud83d\udd34 STALE CLAUSE, CORRECTED 2026-09-01: this said \"Whether the title shows the plate when it is REACHED AGAIN -- by (B) from the main menu, or after the attract movie -- is not measured\". It IS measured now, and has been since 2026-08-30: after (B) from the menu the plate is re-drawn, pressed at 351.2 s with its pulse back at 358.5 s (the Decoder, nav-autorepeat-and-settled-b data). The port re-arms the overlay on arrival at the title by any path, and that is correct. What stayed true is the structural half -- the declaration lives on the boot STEP and is looked up from there, so a screen that gains an overlay gets it on both paths at once. \u26a0\ufe0f What is STILL not measured is whether the returned plate FADES or appears at once; the 7.3 s between press and pulse is consistent with a transition plus the declared 214->236 fade, but that is consistency, not a measurement of the ramp on this path.",
|
||||
"no_pulse_why": "The port draws the plate arriving and then holding. It does not pulse it. The RE agent identifies the pulse as the plate's FOCUS RECORD `ptbtn00f` -- a glow ramping 0x00 to 0x50 and back, t=6..105 -- not as a loop of `ptbtn00`'s own group, which was the port's earlier reading and was wrong. Looping that record is a candidate the port has NOT taken: its group is 105 timed units plus an AUTHORED 24-unit exit ramp, and hitting the measured 2.24 s mean requires composing that authored constant with a loop assumption, which is tuning rather than measuring. Filed in BLOCKED.md. \ud83d\udccc SOURCE, added 2026-09-01: docs/re/structures/plate-pulse-measured.md, and the phase-lock caveat that bounds what a gated capture can show is docs/re/structures/plate-pulse-phase-lock.md."
|
||||
},
|
||||
"why": "HANDOFF Q2/Q6: the boot reaches the title after the intro movie. This is the LAST step, and a last step is where the sequence stops rather than fading out -- a boot that ends by fading to black looks like a boot that crashed. P5 gave the title somewhere to go, but that is a HANDOVER and not another boot step: `--boot` still stops here, and `--boot --play` hands the same held title to the menu flow, where (A) opens TITLE_MENU. Kept as a stop rather than folded into `screens` because what the boot does is authored from a measured sequence, and what (A) does is a separate measurement."
|
||||
}
|
||||
],
|
||||
"dwell": {
|
||||
"_": [
|
||||
"DELIBERATELY EMPTY. Each screen's dwell is its own keyframe group -- the",
|
||||
"publisher wordmark reaches its hold at t=235 (3.92 s) and the developer",
|
||||
"logos at t=190 (3.17 s), both read from the disc. Holding beyond that would",
|
||||
"be a number nobody has measured, so the sequencer holds for zero extra time",
|
||||
"and the pacing is the disc's own.",
|
||||
"NOT SET -- because the dwell is DECLARED, and the port already plays it.",
|
||||
"",
|
||||
"When a capture times the real boot, the extra hold per screen goes here."
|
||||
"This key has now been wrong in two opposite directions, and the second was",
|
||||
"mine, so both are recorded.",
|
||||
"",
|
||||
"It first said 'a screen's dwell is its OWN keyframe group'. Then GP_TITLE",
|
||||
"build 4 was measured dwelling ~1100 presented frames against a declared ~120,",
|
||||
"and I generalised that into 'the boot is KNOWN TOO FAST [refuted] on both splashes'.",
|
||||
"\ud83d\udd34 THAT WAS AN OVER-CORRECTION and it is withdrawn. Build 4 is the title: its",
|
||||
"exit is caused by something outside its timeline, so it holds. A splash's exit",
|
||||
"is caused by nothing, so it plays its declared timeline and leaves. The title",
|
||||
"is the exception, not the rule, and one screen was never enough to overturn",
|
||||
"the other two.",
|
||||
"",
|
||||
"MEASURED 2026-08-29 by the Decoder over 3 cold boots",
|
||||
"(docs/re/structures/boot-splash-dwells-are-declared.md):",
|
||||
"",
|
||||
" publisher declared t=0..255 = 4.250 s corpus 4.30 / 4.60 / 4.37",
|
||||
" developer declared t=0..210 = 3.500 s corpus 3.51 / 3.50 / 3.37",
|
||||
"",
|
||||
"The developer agrees to 1.1 %, two of its three runs to 0.3 %. The port emits",
|
||||
"4.400 s and 3.650 s -- each declared value plus the 9-unit black hold, exactly.",
|
||||
"So the pacing was right all along and nothing changes in the code.",
|
||||
"",
|
||||
"\ud83d\udd34 AND THE UNIT STAYS UNITS, NOT SECONDS. The same two dwells timed in the",
|
||||
"Decoder's own container came out 15-20 % LONGER than both the declared values",
|
||||
"and the corpus -- same disc, same timeline -- and three independent readings",
|
||||
"of that container's rate disagree with each other. A seconds figure records",
|
||||
"one emulator's pacing on one run. The units are on the disc. If anything ever",
|
||||
"goes in `dwell` it is an extra hold in UNITS, and only for a screen that is",
|
||||
"measured to wait beyond its group."
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"_": [
|
||||
"MEASURED off the running game, HANDOFF Q5 -- none of it is on the disc.",
|
||||
"It lives here rather than in GDScript so that a reader can see it is a",
|
||||
"measurement and delete it the day a field on the disc states it."
|
||||
],
|
||||
"wrap": true,
|
||||
"wrap_why": "HANDOFF Q5: up/down move one item and WRAP at both ends. Measured on the 5-item main menu AND the 3-item EXTRAS, so it is a menu rule and not a per-screen one (docs/game/navigation.md, branch auto/no-disc-and-menu-captures 3a87a26).",
|
||||
"wrap_kind": "measured",
|
||||
"left_right": "nothing",
|
||||
"left_right_why": "HANDOFF Q5: left/right do nothing. Measured. Implemented as an explicit no-op rather than by omission, so that 'we never wired it' and 'the game ignores it' are distinguishable in the code.",
|
||||
"left_right_kind": "measured",
|
||||
"input_during_transition": "ignored",
|
||||
"input_during_transition_why": "AUTHORED, and NOT measured -- nobody has watched what the game does with a button pressed mid-fade. Ignoring is the choice that invents the least: it cannot queue a press the game might have dropped. Ask the RE agent before relying on it. \ud83d\udccc WHERE THE ASK LIVES, added 2026-09-01: docs/port/BLOCKED.md carries it, and until now this why said \"ask the RE agent\" without naming where the question is recorded -- a pointer with no destination. An `authored` kind still needs a citation, because the thing to cite is the OPEN QUESTION the choice stands in for; without it, an invented value and a placeholder for a measurement read the same.",
|
||||
"input_during_transition_kind": "authored",
|
||||
"auto_repeat": false,
|
||||
"auto_repeat_why": "MEASURED 2026-08-30, Decoder daf8f47: a 2.0 s held (down) moves the cursor EXACTLY ONCE. Their counter passes its own control first -- a single 0.12 s tap gives exactly 1 spike, the hold gives 1, move spike 0.0202-0.0220 against a 0.0003-0.0038 floor. The port's edge-triggered _input already behaved this way; what changed is that it is now a MEASUREMENT rather than an unexamined consequence of how the handler was written. HANDOFF Q5's 'up / down' row is split at the source: one-item-per-press (evidenced by the 4-press wrap count) from no-auto-repeat (which had nothing until this run).",
|
||||
"auto_repeat_kind": "measured"
|
||||
},
|
||||
"screens": {
|
||||
"_": [
|
||||
"What each button does. NOT FILLED IN -- that is P5. HANDOFF Q4 measured the",
|
||||
"destination screens and the RE agent later decoded that a transition is a",
|
||||
"lookup by NAME, giving a candidate vocabulary (TITLE_SCREEN, TITLE_MENU,",
|
||||
"LOADING, DIFFICULTY, EXTRA_MENU, TUTORIAL_MENU). Those are the right `goto`",
|
||||
"targets when this is written, marked as the name match they are."
|
||||
]
|
||||
"What each button does. The NAVIGATION ORDER is not here -- it is derived,",
|
||||
"in each screen file's `buttons` (button-role elements sorted by resting Y).",
|
||||
"Only the destinations, the initial focus and the cancel target are",
|
||||
"authored, because only those are measurements or decisions.",
|
||||
"",
|
||||
"`goto` is an EXPORTED SCREEN NAME or null. `goto_name` is the game's own",
|
||||
"screen vocabulary from the decoded transition lookup -- carried so the",
|
||||
"binding is not lost, and marked below as the NAME MATCH it is, never as a",
|
||||
"measurement (HANDOFF: the strings are what the call sites reference, not",
|
||||
"proven arguments, and the same list mixes in TEXT_FONT and GAMMA_RGB).",
|
||||
"",
|
||||
"`goto: null` with a `blocked` note means the destination screen is real and",
|
||||
"measured but is NOT IN THIS EXPORT -- it lives in another archive. That is a",
|
||||
"milestone boundary, not an unknown."
|
||||
],
|
||||
"title": {
|
||||
"on_accept": {
|
||||
"goto": "main_menu",
|
||||
"goto_name": "TITLE_MENU",
|
||||
"goto_name_kind": "name match, not measured",
|
||||
"goto_name_why": [
|
||||
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
|
||||
"screens are measured; the ids are a name match onto the executable's class",
|
||||
"names.\" So `TITLE_MENU` is a string that exists in the executable and plausibly",
|
||||
"denotes this screen -- nothing observed binds it to this transition.",
|
||||
"",
|
||||
"It is carried so a reader can search for it and so the port never has to",
|
||||
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
|
||||
"a screen file, and this field is documentation.",
|
||||
"",
|
||||
"\ud83d\udd34 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
|
||||
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
|
||||
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
|
||||
"as BORROWED rather than ok, because a label resting on a neighbour's",
|
||||
"argument reads as evidenced and is not."
|
||||
],
|
||||
"why": "MEASURED, HANDOFF: (A) on the title opens the main menu, with (A) on the boot title as the control in the same run."
|
||||
},
|
||||
"on_cancel": null,
|
||||
"on_cancel_why": "MEASURED 2026-08-30, Decoder daf8f47, docs/re/data/nav-autorepeat-and-settled-b.txt: twenty seconds after a delivery-confirmed B the screen is still the title with PRESS (A) BUTTON up. The run waited for the PLATE PULSE -- the title's own settled signature -- before pressing, which is exactly what the earlier confounded attempt did not. This cell briefly said 'MEASURED, HANDOFF Q5' on no evidence, then said AUTHORED once that was caught; it is now measured for real. Value unchanged throughout: null.",
|
||||
"on_cancel_kind": "measured"
|
||||
},
|
||||
"main_menu": {
|
||||
"initial_focus": "ptbtn01",
|
||||
"initial_focus_kind": "measured",
|
||||
"focus_persists": true,
|
||||
"focus_persists_kind": "measured",
|
||||
"focus_persists_why": [
|
||||
"MEASURED 2026-08-30, Decoder: the main menu REMEMBERS ITS CURSOR across a",
|
||||
"round trip through the title. (B) out and (A) back returns to the item you",
|
||||
"left, not to a default. Their control passed first -- two delivery-confirmed",
|
||||
"DOWNs moved the cursor exactly two items before the round trip, so the",
|
||||
"cursor demonstrably was not where it started.",
|
||||
"",
|
||||
"The port reset to `initial_focus` on every entry, so this was a real defect",
|
||||
"and not a refinement: a player who moved to EXTRAS, pressed (B), then (A),",
|
||||
"landed back on NEW GAME.",
|
||||
"",
|
||||
"\ud83d\udd34 SCOPED TO THIS SCREEN ON PURPOSE, and the scope is the authored part.",
|
||||
"The measurement is of the MAIN MENU. Making it a menu-wide rule would be",
|
||||
"n=1 wearing a rule's clothes -- and here it would actively contradict a",
|
||||
"measurement, because `extras` opens on MISSION SELECT as a MEASURED initial",
|
||||
"focus, and a remembered cursor would override it on re-entry. `wrap` is a",
|
||||
"menu rule because it was measured on two screens; this was measured on one.",
|
||||
"",
|
||||
"\u26a0\ufe0f WHAT IS NOT KNOWN: whether the memory survives a return to the BOOT",
|
||||
"(as opposed to the title), and whether any other screen has it. Ask before",
|
||||
"widening this.",
|
||||
"",
|
||||
"\ud83d\udd34 CORRECTED 2026-08-30, SAME DAY, by the Decoder: the paragraph above argued",
|
||||
"the scope from `extras` having a MEASURED initial focus that a remembered",
|
||||
"cursor would override. That is a good reason to be CAUTIOUS and NOT a finding",
|
||||
"that `extras` resets. Nothing has measured what a submenu's own cursor does on",
|
||||
"re-entry: the corpus has EXTRAS' opening item from ONE entry, and (B) restoring",
|
||||
"the PARENT's focus 4/4, and neither answers it.",
|
||||
"",
|
||||
"So `focus_persists: false` everywhere else is THE PORT'S DEFAULT, not the",
|
||||
"game's behaviour. It invents the least and it preserves the one measurement",
|
||||
"there is. `tools/port/contract-check` asserts only the main-menu half against",
|
||||
"the contract and reports the scope as a GUARD, because for one iteration it",
|
||||
"asserted non-persistence as though it had been measured -- which would have",
|
||||
"held the port to the wrong behaviour and passed while doing it.",
|
||||
"",
|
||||
"\u2754 The Decoder is measuring EXTRAS re-entry now. Do not build on the",
|
||||
"non-persistence half until it returns.",
|
||||
"",
|
||||
"\ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: docs/re/data/focus-persists-across-title.txt carries the round trip, and docs/re/data/extras-focus-resets.txt carries the contrasting submenu result that keeps this scoped to one screen."
|
||||
],
|
||||
"initial_focus_why": [
|
||||
"MEASURED 2026-08-30 (later) -- `NEW GAME` on a fresh boot, 2/2 fresh boots,",
|
||||
"both the FIRST menu entry. Decoder, HANDOFF `bf9e07f`, section \"correcting",
|
||||
"today's focus delivery\"; ring row y=225.5 against a measured 79.25 px step,",
|
||||
"data in docs/re/data/menu-focus-reader-offset.txt.",
|
||||
"",
|
||||
"\ud83d\udd34 THIS FIELD WAS `authored` UNTIL NOW AND THE UPGRADE IS NOT BECAUSE IT",
|
||||
"AGREES WITH ME. The value did not change; its standing did. The confirmation",
|
||||
"is a direct reading of a fresh boot's first menu entry, independent of the",
|
||||
"reasoning that chose NEW GAME here -- and the Decoder had said explicitly that",
|
||||
"my agreeing with their records was no evidence, which was correct at the time.",
|
||||
"",
|
||||
"\u2705 AND IT SURVIVES A REBOOT -- MEASURED 2026-08-31. Six fresh boots all",
|
||||
"opened on NEW GAME, and THREE of them followed a session that ended with the",
|
||||
"cursor on EXTRAS or OPTIONS. That is what makes it a test of persistence",
|
||||
"rather than six repetitions of the same start.",
|
||||
"",
|
||||
"\u26a0\ufe0f REACH, and it is the Decoder's own caveat rather than mine: every one of",
|
||||
"those sessions ended with the emulator KILLED, not shut down cleanly. A game",
|
||||
"that writes menu state on a clean exit never gets the chance, so this",
|
||||
"measures 'does not survive a KILLED session'. If a real console remembers a",
|
||||
"cursor across a power cycle, that does not contradict this.",
|
||||
"",
|
||||
"\u26a0\ufe0f WHY 'FIRST ENTRY' IS LOAD-BEARING: the menu REMEMBERS ITS CURSOR (see",
|
||||
"`focus_persists`), so any reading not taken on a fresh boot's first entry is",
|
||||
"measuring HISTORY, not what the screen opens on. That objection is what",
|
||||
"invalidated the earlier TUTORIAL/NEW GAME disagreement, and this measurement",
|
||||
"is the one that is immune to it.",
|
||||
"",
|
||||
"The superseded reasoning is kept below, because it is what made the wait cheap:",
|
||||
"the field existed and was labelled honestly, so arriving at a measurement was a",
|
||||
"label change and not an archaeology problem.",
|
||||
"",
|
||||
" (was) AUTHORED, standing in for HANDOFF Q5, which measured that initial focus is NOT STABLE: four boots of the same harness opened on TUTORIAL, TUTORIAL, NEW GAME, NEW GAME. A port has to open on something. ptbtn01 (NEW GAME) is picked because it is one of the two states actually observed and it is the top item, so a reader can predict it. It is a CHOICE. Delete this the day the RE agent finds what selects it. CORROBORATED 2026-08-29, and still not decoded: the committed capture live-main-menu.png has NEW GAME focused. Identified by rendering all five focus states and taking the minimum difference -- 531 differing pixels against 6080-7094 for the others, an 11.5x margin -- with the method controlled on live-main-menu-options-focused.png, whose answer is in its filename and which it picks by 4.7x. That means the port's choice matches the state of one committed frame. It does NOT make focus stable: Q5's four boots gave TUTORIAL, TUTORIAL, NEW GAME, NEW GAME, and this identifies one frame rather than a rule. Delete this entry the day something says what SELECTS it. TIGHTENED 2026-08-29: Q5 now has SIX boots, and the shape is sharper than 'unstable' -- TUTORIAL x3, NEW GAME x3, and NO OTHER ITEM EVER OBSERVED. So it is not uniform over five buttons; whatever selects it has to explain a two-way split. That does not change this choice (NEW GAME remains one of exactly two observed states, and it is the state of the committed capture) but it does change what would REFUTE it: a boot opening on LOAD GAME, OPTIONS or EXTRAS would break the two-way shape, and a rule that predicts the split would delete this entry outright.",
|
||||
" (was) ",
|
||||
" (was) \u2705 CONSISTENT WITH THE ONE CAPTURE, measured 2026-08-30. Rendering each of the",
|
||||
" (was) five buttons focused against `live-main-menu.png` gives 0.0705 % for ptbtn01",
|
||||
" (was) and 0.72-0.84 % for the other four -- a 10x discrimination. So that capture",
|
||||
" (was) shows NEW GAME focused, and the authored choice matches it.",
|
||||
" (was) ",
|
||||
" (was) \u26a0\ufe0f THIS DOES NOT OVERTURN Q5. Q5 measured initial focus as UNSTABLE across",
|
||||
" (was) four boots; one capture showing ptbtn01 is consistent with that and does not",
|
||||
" (was) contradict it. What the measurement establishes is narrower and still worth",
|
||||
" (was) having: the port's focus rendering is distinctive enough that a capture",
|
||||
" (was) identifies which button is focused, and this authored value is not at odds",
|
||||
" (was) with the only frame we can check it against. It stays AUTHORED."
|
||||
],
|
||||
"on_cancel": {
|
||||
"goto": "title",
|
||||
"goto_name": "TITLE_SCREEN",
|
||||
"goto_name_kind": "name match, not measured",
|
||||
"goto_name_why": [
|
||||
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
|
||||
"screens are measured; the ids are a name match onto the executable's class",
|
||||
"names.\" So `TITLE_SCREEN` is a string that exists in the executable and plausibly",
|
||||
"denotes this screen -- nothing observed binds it to this transition.",
|
||||
"",
|
||||
"It is carried so a reader can search for it and so the port never has to",
|
||||
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
|
||||
"a screen file, and this field is documentation.",
|
||||
"",
|
||||
"\ud83d\udd34 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
|
||||
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
|
||||
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
|
||||
"as BORROWED rather than ok, because a label resting on a neighbour's",
|
||||
"argument reads as evidenced and is not."
|
||||
],
|
||||
"kind": "measured",
|
||||
"why": "MEASURED 2026-08-30, delivery-confirmed (B = 0x5801), 73.5 % of pixels changed, and both captures name themselves. Latency <= 0.4 s and NO loading screen in between, which matters because the disc carries four pgloading_* screens. This entry previously read 'likely but UNPROVEN': it had been seen once without a capture, and the title ALSO returns on its own after ~8-10 s idle, so an observer could not tell a response from a timeout. The <= 0.4 s latency is what kills that confound -- it is twenty times faster than the idle return. Decoder 86a8ce7, menu-navigation-semantics.md row 'B on the main menu', docs/re/data/b-on-main-menu.txt."
|
||||
},
|
||||
"buttons": {
|
||||
"ptbtn01": {
|
||||
"label": "NEW GAME",
|
||||
"goto": null,
|
||||
"goto_name": "DLG_SELECT_DIFFICULTY",
|
||||
"goto_name_kind": "name match, not measured",
|
||||
"goto_name_why": [
|
||||
"\u2705 CORRECTED 2026-08-31: this read `DIFFICULTY`, and the destination is a",
|
||||
"DIALOG rather than a GamePart -- `DLG_SELECT_DIFFICULTY`, `GP_DIALOG.pak`",
|
||||
"entries 2/3 [see the withdrawal below]. Decoder, TWO arguments [corrected below]; the geometry one is",
|
||||
"re-derived here with this port's own reader: entries 2 and 3 are the ONLY",
|
||||
"builds in that archive carrying `pcbtn00`-`pcbtn03`, at design rows",
|
||||
"259/329/399/469, spacing exactly 70. See",
|
||||
"`crates/sylpheed-export/examples/dialog_rows.rs`.",
|
||||
"",
|
||||
"\ud83d\udd34 SO THE FOUR EXTERNAL DESTINATIONS ARE NOT UNIFORM: three open GameParts",
|
||||
"and this one opens a dialog. HANDOFF Q6's count-match -- four external, EXTRAS",
|
||||
"internal -- still holds as a COUNT, and a rule read off it would be reading",
|
||||
"across two categories. The Decoder sent that count with disc support",
|
||||
"yesterday and weakened it themselves today; recorded at the weaker strength.",
|
||||
"",
|
||||
"\u2705 THE REACH IS NOW BOUNDED -- 2026-08-31, and both agents scanned for it.",
|
||||
"",
|
||||
"It read: \"another four-button dialog with the same rows would be",
|
||||
"indistinguishable by this evidence\". The Decoder searched every build in",
|
||||
"every pak for four buttons within 6 px of those rows and found ZERO rivals.",
|
||||
"Re-run here with this port's reader and a BROADER filter -- any element",
|
||||
"whose name contains `btn`, not only `pcbtn`, so a rival under a different",
|
||||
"naming convention would still be caught: 2 859 builds across 33 paks,",
|
||||
"EXACTLY 2 matches, entries 2 and 3. The run carries its own known positive:",
|
||||
"fewer than 2 would mean the reader cannot see the incumbents and its zero",
|
||||
"would mean nothing.",
|
||||
"",
|
||||
"\u2705 And the name is now backed by a TABLE ENTRY rather than an inference",
|
||||
"from a string list: every `DLG_` name in the image sits in a 12-byte record",
|
||||
"(id, name pointer, handler [corrected]) spanning 0x820A0A2C-0x820A0D68 -- 70 names,",
|
||||
"70 records, none unmatched. `DLG_SELECT_DIFFICULTY` is **id 2000**.",
|
||||
"",
|
||||
"\ud83d\udd34 \"THREE INDEPENDENT ROUTES\" CORRECTED TO TWO -- 2026-08-31, by the Decoder,",
|
||||
"and I had relayed the count unchecked for the second time from one delivery.",
|
||||
"",
|
||||
"The image leg says DIFFICULTY is a dialog and names no entry, so alone it",
|
||||
"identifies nothing. The disc and oracle legs are ONE COMPOUND ARGUMENT: the",
|
||||
"capture is compared against the disc's rows. What makes that discriminating is",
|
||||
"the EXCLUSION SCAN -- zero rivals within 6 px anywhere on the disc -- and that",
|
||||
"is what the word \"three\" was taking credit for. The conclusion is unchanged;",
|
||||
"the evidence is two arguments, one of them compound, and was never three.",
|
||||
"",
|
||||
"\ud83d\udccc The test that falls out of it, theirs: ask of an n-routes claim not whether",
|
||||
"the routes are correct but whether ANY COULD HAVE COME OUT DIFFERENTLY GIVEN",
|
||||
"THE OTHERS. That is an exclusion argument, and it is usually absent.",
|
||||
"",
|
||||
"\ud83d\udd34 WITHDRAWN 2026-08-31 -- \"AN EN/JP PAIR\", AND I RELAYED IT.",
|
||||
"",
|
||||
"The Decoder stated entries 2/3 as a language pair in the same HANDOFF row that",
|
||||
"identifies DIFFICULTY, as a fact, and has withdrawn it: nothing established the",
|
||||
"pairing. I copied it into this `why` -- twice -- in the SAME SENTENCE where I",
|
||||
"was careful to say my re-derivation confirms the geometry and does not name the",
|
||||
"screen. The unchecked half rode along inside the clause I had checked.",
|
||||
"",
|
||||
"What the scan actually shows is that adjacent GP_DIALOG entries are UNRELATED",
|
||||
"DIALOGS: 26 of 65 adjacent pairs differ in BUTTON COUNT, which no language pair",
|
||||
"can. Identical element sets is the language signature in GP_TITLE; here it is",
|
||||
"equally consistent with a duplicate. So `2/3` are two builds with the same four",
|
||||
"buttons at the same rows, and calling them EN and JP is an assumption.",
|
||||
"",
|
||||
"\u26a0\ufe0f THE IDENTIFICATION DOES NOT REST ON IT -- unique four-button geometry with",
|
||||
"zero rivals disc-wide, plus the oracle capture. The pairing was decoration on a",
|
||||
"conclusion that stands without it, which is exactly why it travelled unchecked.",
|
||||
"",
|
||||
"\u2705 RESTORED 2026-08-31, ON A MEASUREMENT RATHER THAN A RELAY. The Decoder took",
|
||||
"the `ja` capture of DIFFICULTY that was missing and 2/3 ARE English/Japanese:",
|
||||
"EN vs JP differ in 1.82 % of pixels in FOUR BANDS AND NOWHERE ELSE -- the",
|
||||
"heading (DIFFICULTY -> the JP heading), the ring by 2 px, the BACK label, and",
|
||||
"the footer. EASY/NORMAL/HARD are NOT in the differing set: the Japanese release",
|
||||
"leaves the three difficulty names in Latin script, which is why the disc figure",
|
||||
"is only 2.77 % of bytes against 1.82 % of pixels.",
|
||||
"",
|
||||
"\ud83d\udccc MY OBJECTION WAS NOT WRONG AND IS NOT WITHDRAWN. It was that IDENTICAL",
|
||||
"ELEMENT SETS DO NOT IMPLY A LANGUAGE PAIR -- 26 of 65 adjacent pairs differ in",
|
||||
"button count, so adjacency proves nothing. That argument still holds; what has",
|
||||
"changed is that the conclusion now rests on a direct locale capture instead of",
|
||||
"on that inference. A bad argument for a true claim is still a bad argument, and",
|
||||
"the claim was correctly out of this file until somebody went and looked.",
|
||||
"",
|
||||
"\u26a0\ufe0f REACH, THEIRS: one JP boot, one screen, does not generalise. GP_TITLE 4/7 is",
|
||||
"known to differ by MORE than text -- entry 7 carries nine sprites entry 4 lacks.",
|
||||
"Nothing in the port keys off locale today; this is recorded, not consumed.",
|
||||
"",
|
||||
"\ud83d\udd34 RECORD LAYOUT CORRECTED 2026-09-01, and I had copied the wrong one. I wrote",
|
||||
"\"(handler, id, name pointer)\"; it is {id, name_ptr, handler} -- the same three",
|
||||
"fields shifted one word, so every record was being credited with the PREVIOUS",
|
||||
"record's handler. The Decoder caught it with a control dump: under the old",
|
||||
"alignment record 0 had a handler of 0x10000000, which is not a code address.",
|
||||
"ids and names are unaffected and DLG_SELECT_DIFFICULTY is still 2000, so",
|
||||
"nothing here moves except the sentence.",
|
||||
"",
|
||||
"\ud83d\udccc FOURTH aside of theirs relayed into this file. The first three were an EN/JP",
|
||||
"pairing, a leg count and an independence claim -- all decorative. This one is a",
|
||||
"STRUCTURE, which is worse: a wrong field order is the kind of thing a later",
|
||||
"reader builds on, and it carried no weight here only by luck.",
|
||||
"",
|
||||
"\u2754 AND THE JOIN IS NOT REACHABLE BY THAT ROUTE -- their negative, with their",
|
||||
"reach. All three handlers load the same global at 0x828E2B14 and take addresses",
|
||||
"at 0x828E45E0/4640/467C, every one inside a 364 601-byte contiguous zero run:",
|
||||
"BSS, populated only at runtime. Controlled, because an all-zero read is also",
|
||||
"what a wrong address gives, and the dialog table itself reads non-zero through",
|
||||
"the same arithmetic.",
|
||||
"",
|
||||
"\u26a0\ufe0f That closes the DIALOG HANDLERS, not the image. The archive loader and any",
|
||||
"id-keyed table elsewhere are unexamined, so \"not in the image\" is NOT",
|
||||
"established. Recorded as a route rather than an answer, which is how they sent",
|
||||
"it.",
|
||||
"",
|
||||
"\u2754 STILL UNBOUND, and it is what would make this airtight: nothing connects",
|
||||
"id 2000 to a pak entry. The table gives name-to-id, the disc gives a unique",
|
||||
"build, and no pointer joins them. The tie is UNIQUENESS PLUS THE ORACLE",
|
||||
"CAPTURE, not a binding -- so if a rival build ever appeared, this",
|
||||
"identification would go with it.",
|
||||
"",
|
||||
"button count and geometry, NOT by a binding from the `DLG_` name to a pak",
|
||||
"entry. No such binding was found. Another four-button dialog with the same",
|
||||
"rows would be indistinguishable by this evidence -- my re-derivation",
|
||||
"confirms the geometry and does not name the screen.",
|
||||
"",
|
||||
" (was) NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
|
||||
" (was) screens are measured; the ids are a name match onto the executable's class",
|
||||
" (was) names.\" So `DIFFICULTY` is a string that exists in the executable and plausibly",
|
||||
" (was) denotes this screen -- nothing observed binds it to this transition.",
|
||||
" (was) ",
|
||||
" (was) It is carried so a reader can search for it and so the port never has to",
|
||||
" (was) invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
|
||||
" (was) a screen file, and this field is documentation.",
|
||||
" (was) ",
|
||||
" (was) \ud83d\udd34 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
|
||||
" (was) labels rested on a sibling `why` that argues the DESTINATION -- a different",
|
||||
" (was) claim from where the NAME came from. `tools/port/audit-kinds` reports that",
|
||||
" (was) as BORROWED rather than ok, because a label resting on a neighbour's",
|
||||
" (was) argument reads as evidenced and is not."
|
||||
],
|
||||
"blocked": "DIFFICULTY is not in this export. MEASURED destination (EASY/NORMAL/HARD/BACK, opening on NORMAL, then SELECT DATA) but it is not a GP_TITLE build, so there is no screen file to go to yet.",
|
||||
"skipped_chain": [
|
||||
"DIFFICULTY",
|
||||
"SELECT DATA"
|
||||
],
|
||||
"skipped_chain_why": "THE PORT SKIPS TWO MEASURED SCREENS HERE, AND IT SAYS SO OUT LOUD RATHER THAN PRETENDING. The real chain is NEW GAME -> DIFFICULTY -> SELECT DATA -> (A) on a save slot -> ~4.5 s -> S00A. DIFFICULTY and SELECT DATA are MEASURED destinations (HANDOFF Q4) but neither is a GP_TITLE build, so there is no screen file to go to. The port jumps from NEW GAME to the one thing in that chain it has, and the runtime prints what it skipped on every run. This is a GAP, not a sequence: nobody may read the port's behaviour here as what the game does.",
|
||||
"skipped_chain_kind": "measured",
|
||||
"then_video": "S00A",
|
||||
"then_video_why": "P7. HANDOFF Q9, DECODED from the movie manifest: MS00A -> S00A.wmv is the new-game intro, 93.9 s. Its POSITION is measured as well -- the movie starts ~4.5 s after (A) on the save slot, matched off the running game at 0.96-1.000 with a strictly monotone playhead over 25 consecutive 0.5 s samples.",
|
||||
"then_video_kind": "decoded",
|
||||
"unobserved_why": "WHAT FILLS THE ~4.5 s between the save slot and the movie is NOT KNOWN. The oracle run that would have shown it hit the already-documented sub_823070B0 cache crash after SELECT DATA. GP_TITLE does carry a LOADING screen -- entries 0/1 and 12/15, whose elements are every one of them named pgloading_* -- and LOADING is in the game's own screen vocabulary, but nobody has watched it appear here and the port does NOT put it in the chain on that basis. \ud83d\udccc WHERE THE OPEN QUESTION LIVES, added 2026-09-01: docs/port/BLOCKED.md carries the row -- 'what fills the 4.5 s before S00A'. An explicit unknown still needs a citation, or it cannot be distinguished from an unexamined one.",
|
||||
"skippable": true,
|
||||
"skippable_why": "HANDOFF Q9, MEASURED: one (A) press skips a movie -- the title was reached at 57 s against a 193 s baseline. Same rule the boot intro already uses.",
|
||||
"skippable_kind": "measured",
|
||||
"after_video": {
|
||||
"goto": "title",
|
||||
"kind": "authored",
|
||||
"why": "AUTHORED, and it has to be: the game goes into MISSION 1, and gameplay is out of scope (PORT-MISSION section 7). P7's gate asks for 'plays, then returns to a defined state' -- this is that state. The title is chosen over the main menu because the boot's own end state is the title, so a run that finishes the new-game intro lands somewhere a player can start again from. Nothing measured says the game does this."
|
||||
}
|
||||
},
|
||||
"ptbtn02": {
|
||||
"label": "LOAD GAME",
|
||||
"goto": null,
|
||||
"goto_name": null,
|
||||
"blocked": "The save-slot list is GP_SAVE_LOAD, not in this export. Destination MEASURED."
|
||||
},
|
||||
"ptbtn03": {
|
||||
"label": "TUTORIAL",
|
||||
"goto": null,
|
||||
"goto_name": "TUTORIAL_MENU",
|
||||
"goto_name_kind": "name match, not measured",
|
||||
"goto_name_why": [
|
||||
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
|
||||
"screens are measured; the ids are a name match onto the executable's class",
|
||||
"names.\" So `TUTORIAL_MENU` is a string that exists in the executable and plausibly",
|
||||
"denotes this screen -- nothing observed binds it to this transition.",
|
||||
"",
|
||||
"It is carried so a reader can search for it and so the port never has to",
|
||||
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
|
||||
"a screen file, and this field is documentation.",
|
||||
"",
|
||||
"\ud83d\udd34 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
|
||||
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
|
||||
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
|
||||
"as BORROWED rather than ok, because a label resting on a neighbour's",
|
||||
"argument reads as evidenced and is not."
|
||||
],
|
||||
"blocked": "The lesson list is not a GP_TITLE build. Destination MEASURED."
|
||||
},
|
||||
"ptbtn04": {
|
||||
"label": "OPTIONS",
|
||||
"goto": "options",
|
||||
"goto_name": null,
|
||||
"goto_why": "UNBLOCKED 2026-09-03. This read goto:null with blocked: 'The settings menu is GP_OPTIONS, not in this export.' GP_OPTIONS is in the export now -- authored/screen_names.json export_archives -- and entry 19 is its root, named `options` from the text it renders: GAME SETTINGS, CONTROL SETTINGS, SOUND SETTINGS, SCREEN SETTINGS, BACK. docs/port/options-screens.md. The destination itself was always MEASURED; only the screen file was missing.",
|
||||
"goto_kind": "measured-destination-newly-exported",
|
||||
"limits": [
|
||||
"\ud83d\udd34 THE SCREEN OPENS BUT DOES NOT NAVIGATE. Its five rows are kind 0x3003 and the exporter only treats 0x3002 as a button, so export buttons[] is empty and up/down move nothing. \u24b7 backs out correctly.",
|
||||
"What 0x3003 MEANS is a decode question and is with the Decoder -- not widened here on the port's authority. The circumstantial case is strong (five rows, each with a focus record, on a screen whose own text lists five options) and circumstantial is exactly the standard this project keeps getting burned by.",
|
||||
"None of the sub-screens is wired. Which row leads where is read off content, not measured navigation."
|
||||
]
|
||||
},
|
||||
"ptbtn05": {
|
||||
"label": "EXTRAS",
|
||||
"goto": "extras",
|
||||
"goto_name": "EXTRA_MENU",
|
||||
"goto_name_kind": "name match, not measured",
|
||||
"goto_name_why": [
|
||||
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
|
||||
"screens are measured; the ids are a name match onto the executable's class",
|
||||
"names.\" So `EXTRA_MENU` is a string that exists in the executable and plausibly",
|
||||
"denotes this screen -- nothing observed binds it to this transition.",
|
||||
"",
|
||||
"It is carried so a reader can search for it and so the port never has to",
|
||||
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
|
||||
"a screen file, and this field is documentation.",
|
||||
"",
|
||||
"\ud83d\udd34 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
|
||||
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
|
||||
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
|
||||
"as BORROWED rather than ok, because a label resting on a neighbour's",
|
||||
"argument reads as evidenced and is not."
|
||||
],
|
||||
"why": "MEASURED, HANDOFF Q4: EXTRAS opens GP_TITLE build 6. It is the ONLY main-menu destination inside this archive, and therefore the only (A)-into-a-submenu the P5 gate can actually walk."
|
||||
}
|
||||
},
|
||||
"labels_why": "The five labels are read off live-main-menu.png, a capture of the running game (docs/game/navigation.md, branch auto/no-disc-and-menu-captures 3a87a26). They are carried for logs and for a human reading this file; nothing draws them -- the button sprite already has its own text."
|
||||
},
|
||||
"extras": {
|
||||
"initial_focus": "ptbtn11",
|
||||
"initial_focus_kind": "measured",
|
||||
"focus_persists": false,
|
||||
"focus_persists_kind": "measured",
|
||||
"focus_persists_why": [
|
||||
"MEASURED 2026-08-30 -- EXTRAS RESETS. HANDOFF `4ed75e6`: ring back to",
|
||||
"y=347.5 on re-entry after a confirmed DOWN, frame 0.0 % different from the",
|
||||
"first entry, and the screen confirmed by eye as EXTRAS because an earlier",
|
||||
"run was fooled about which screen it was on.",
|
||||
"",
|
||||
"\ud83d\udccc WRITTEN EXPLICITLY, THOUGH THE PORT'S DEFAULT IS ALREADY false. The",
|
||||
"absent key and the measured false behave identically and mean completely",
|
||||
"different things: one is 'nobody looked', the other is 'the game was",
|
||||
"watched doing it'. `tools/port/audit-kinds` can see the second and not the",
|
||||
"first, which is the whole reason for spending a key on it.",
|
||||
"",
|
||||
"\ud83d\udd34 AND THIS IS NOT A VINDICATION OF HOW IT GOT HERE. For one iteration the",
|
||||
"port ASSERTED non-persistence for EXTRAS in `contract-check` while nothing",
|
||||
"had measured it; the Decoder flagged that, and it turned out right. Being",
|
||||
"right by luck does not retroactively make it evidence -- declining to",
|
||||
"generalise the memory was the correct move, and encoding 'not measured",
|
||||
"here' as a positive claim was a different and wrong one that happened to",
|
||||
"land. The measurement is what makes it true; the assertion never did.",
|
||||
"",
|
||||
"\u26a0\ufe0f Do NOT generalise in either direction: main_menu persists, EXTRAS resets,",
|
||||
"and OPTIONS / LOAD GAME / TUTORIAL are untouched."
|
||||
],
|
||||
"initial_focus_why": [
|
||||
"MEASURED, unlike the main menu's: EXTRAS opens focused on MISSION SELECT (live-extras.png). It is authored here only because there is nowhere else to put a measurement -- it is not a choice.",
|
||||
"",
|
||||
"",
|
||||
"\u2705 CAVEAT LIFTED 2026-08-30 -- MEASURED, not a single-entry reading any more.",
|
||||
"HANDOFF `4ed75e6`, docs/re/data/extras-focus-resets.txt: EXTRAS opens at ring",
|
||||
"y=347.5 on MISSION SELECT, moves to 427.5 after one delivery-confirmed DOWN,",
|
||||
"and returns to 347.5 on re-entry with the frame 0.0 % different from the first",
|
||||
"entry. Because this screen RESETS, a single-entry reading of it is not",
|
||||
"measuring history -- which is precisely what made the caveat necessary while",
|
||||
"persistence here was unknown.",
|
||||
"",
|
||||
"\u2705 THE AMBIGUITY IS RESOLVED -- MEASURED 2026-08-31, and it went the way",
|
||||
"that makes `ptbtn11` right for a REASON rather than by coincidence.",
|
||||
"",
|
||||
"A submenu resets to ITS OWN OPENING ITEM, and that item is a per-screen",
|
||||
"default which need NOT be the first. Decoder, docs/re/data/",
|
||||
"difficulty-resets-to-named-item.txt: DIFFICULTY opens on NORMAL (second of",
|
||||
"four); after one confirmed DOWN to HARD, (B) out and (A) back returns to",
|
||||
"NORMAL -- in-cursor 1.0 from where it opened against 93.9 from where it was",
|
||||
"left. Reproduced on a FRESH BOOT and confirmed by eye, not read off the",
|
||||
"2026-08-29 capture.",
|
||||
"",
|
||||
"So the port's `initial_focus` is the reset target, and `buttons[0]` in",
|
||||
"`MenuFlow.initial_focus` is a REPAIR rather than a default -- which is how",
|
||||
"it was already documented, and is now measured rather than principled.",
|
||||
"",
|
||||
"\u2754 STILL OPEN, and not leaned on: whether the reset target MOVES once a",
|
||||
"difficulty has actually been confirmed. A game that remembered your last",
|
||||
"choice would behave differently, and the probe never confirms one -- the",
|
||||
"same SELECT DATA crash that constrains the run prevents testing it.",
|
||||
"",
|
||||
"",
|
||||
"\ud83d\udd34 CORRECTED 2026-08-31. This read \"it matters IF another screen is ever",
|
||||
"authored\" whose opening item is not its first. Such a screen exists and is",
|
||||
"recorded IN THIS FILE: DIFFICULTY, under `main_menu/buttons/ptbtn01`, is",
|
||||
"EASY/NORMAL/HARD/BACK and opens on NORMAL -- the SECOND of four. Measured:",
|
||||
"driven with no d-pad, unchanged for 90 s, matching the committed capture at",
|
||||
"r=+0.999 (Decoder, docs/re/captures/newgame-path/newgame-difficulty.png).",
|
||||
"",
|
||||
"So \"a screen opens on its first item\" is REFUTED as a general description of",
|
||||
"this game. On EXTRAS, TUTORIAL and OPTIONS the named item and the top item",
|
||||
"coincide BY ACCIDENT. A top-item rule would be wrong on DIFFICULTY.",
|
||||
"",
|
||||
"nobody can separate \"resets to MISSION SELECT\" from \"resets to the TOP ITEM\".",
|
||||
"They coincide here -- ptbtn11 is both. The port's value is correct under either",
|
||||
"reading, and the REASON is not established.",
|
||||
"",
|
||||
"The superseded caveat is kept below.",
|
||||
" (was) \u26a0\ufe0f WEAKENED 2026-08-30 -- the OBSERVATION stands, its reading as an INITIAL",
|
||||
" (was) focus does not. It was taken on a single entry. Now that the main menu is known",
|
||||
" (was) to remember its cursor across a round trip, a one-entry reading of any screen",
|
||||
" (was) may be measuring HISTORY rather than what the screen opens on -- the same",
|
||||
" (was) objection that reframed the main menu's TUTORIAL/NEW GAME disagreement.",
|
||||
" (was) ",
|
||||
" (was) Kept as `measured` because the frame really does show MISSION SELECT focused,",
|
||||
" (was) and kept as the port's opening item because it is the only reading there is.",
|
||||
" (was) \ud83d\udd34 If EXTRAS turns out to persist, this becomes history and the kind must",
|
||||
" (was) change with it.",
|
||||
"",
|
||||
"\ud83d\udd34 CHECKED AGAINST THE BYTES 2026-08-31 by both agents -- and NOT independently.",
|
||||
"Settled by fact, not by my inference: the Decoder's 282/362/442 came from",
|
||||
"`crates/sylpheed-formats/examples/extras_button_order.rs`, which calls",
|
||||
"`ui_layout::parse_build` -- THE SAME CRATE this port's export uses. The",
|
||||
"Python RATC parsers in their tree exist and did not produce that number.",
|
||||
"So the two legs are ONE READER USED TWICE, and the agreement carries no",
|
||||
"information about the reader being right; it carries information only about",
|
||||
"two callers of it agreeing, which they could not fail to do.",
|
||||
"",
|
||||
"\u26a0\ufe0f The VALUE is unaffected -- `ptbtn11` is decided by the DIFFICULTY",
|
||||
"measurement and by the reset finding. What died is a word I used about the",
|
||||
"evidence, which is the third such word in three iterations.",
|
||||
"",
|
||||
"is WEAKENED, by my own audit rather than by theirs.",
|
||||
"",
|
||||
"Applying their test to my own sentence: could my reading have come out",
|
||||
"differently given theirs? Only if the implementations differ. Mine is",
|
||||
"`sylpheed_formats::ui_layout::parse_build` via this port's export. Their tree",
|
||||
"does carry separate Python RATC parsers (`kf_record_census.py` and others),",
|
||||
"so a second implementation EXISTS -- but which reader produced their",
|
||||
"282/362/442 is not established by me, and if they used the same crate the",
|
||||
"two legs are one reader used twice.",
|
||||
"",
|
||||
"So: the values agreeing is still evidence, and calling it INDEPENDENT was a",
|
||||
"claim about their tooling that I did not check. Recorded at the strength I",
|
||||
"can support. \u26a0\ufe0f Nothing rests on it -- the row order is also decided by the",
|
||||
"DIFFICULTY measurement -- which is exactly why it went unexamined.",
|
||||
"",
|
||||
"Decoder attempted to refute this value and it survives: `ptbtn11` is the TOP",
|
||||
"button on this screen -- y 282 against 362 and 442 -- so the port is right",
|
||||
"whichever reading of the reset target applies. Confirmed from THIS port's own",
|
||||
"export, a different reader of the same disc: extras 282/362/442, and the main",
|
||||
"menu as a control at 162/242/322/401/482.",
|
||||
"",
|
||||
"\ud83d\udd34 WHICH ALSO MEANS EXTRAS CANNOT SEPARATE the two readings -- named item and",
|
||||
"top item coincide here. It was DIFFICULTY, opening on its second of four, that",
|
||||
"settled it."
|
||||
],
|
||||
"on_cancel": {
|
||||
"goto": "main_menu",
|
||||
"goto_name": "TITLE_MENU",
|
||||
"goto_name_kind": "name match, not measured",
|
||||
"goto_name_why": [
|
||||
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
|
||||
"screens are measured; the ids are a name match onto the executable's class",
|
||||
"names.\" So `TITLE_MENU` is a string that exists in the executable and plausibly",
|
||||
"denotes this screen -- nothing observed binds it to this transition.",
|
||||
"",
|
||||
"It is carried so a reader can search for it and so the port never has to",
|
||||
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
|
||||
"a screen file, and this field is documentation.",
|
||||
"",
|
||||
"\ud83d\udd34 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
|
||||
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
|
||||
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
|
||||
"as BORROWED rather than ok, because a label resting on a neighbour's",
|
||||
"argument reads as evidenced and is not."
|
||||
],
|
||||
"why": "MEASURED, HANDOFF Q5: (B) goes up one level and RESTORES FOCUS to the item you came from. EXTRAS advertises (B) in its own footer -- the red glyph is in ptmsg2.png and absent from the main menu's ptmsg.png."
|
||||
},
|
||||
"buttons": {
|
||||
"ptbtn11": {
|
||||
"label": "MISSION SELECT",
|
||||
"goto": null,
|
||||
"goto_name": null,
|
||||
"blocked": "The stage list is GP_MISSION_SELECT, not in this export. Destination MEASURED."
|
||||
},
|
||||
"ptbtn12": {
|
||||
"label": "MOVIE THEATER",
|
||||
"goto": null,
|
||||
"goto_name": null,
|
||||
"blocked": "NEVER OPENED. docs/game/navigation.md marks this one unknown -- not merely unexported. Do not assume it opens GP_MOVIE_THEATER; that would be a name match dressed as a destination."
|
||||
},
|
||||
"ptbtn13": {
|
||||
"label": "BACK",
|
||||
"goto": "main_menu",
|
||||
"goto_name": "TITLE_MENU",
|
||||
"goto_name_kind": "name match, not measured",
|
||||
"goto_name_why": [
|
||||
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
|
||||
"screens are measured; the ids are a name match onto the executable's class",
|
||||
"names.\" So `TITLE_MENU` is a string that exists in the executable and plausibly",
|
||||
"denotes this screen -- nothing observed binds it to this transition.",
|
||||
"",
|
||||
"It is carried so a reader can search for it and so the port never has to",
|
||||
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
|
||||
"a screen file, and this field is documentation.",
|
||||
"",
|
||||
"\ud83d\udd34 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
|
||||
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
|
||||
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
|
||||
"as BORROWED rather than ok, because a label resting on a neighbour's",
|
||||
"argument reads as evidenced and is not."
|
||||
],
|
||||
"same_as_cancel": true,
|
||||
"why": "MEASURED: EXTRAS' third item is BACK (live-extras.png). Treated as (B): it pops the stack, so focus is restored on the main menu exactly as (B) does. Whether the game distinguishes them is untested and there is no reason here to invent a difference."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,55 +16,151 @@
|
||||
"",
|
||||
"Delete an entry here the day the RE agent decodes a name field."
|
||||
],
|
||||
"export_archives": [
|
||||
"dat/GP_TITLE.pak",
|
||||
"dat/GP_OPTIONS.pak",
|
||||
"dat/GP_SAVE_LOAD.pak"
|
||||
],
|
||||
"export_archives_why": [
|
||||
"WHICH disc archives the export reads screen builds from.",
|
||||
"",
|
||||
"GP_TITLE was the only one for the whole project, hardcoded in the",
|
||||
"exporter. That single constant is why four of the five main-menu",
|
||||
"destinations are dead: authored/flow.json records LOAD GAME, TUTORIAL,",
|
||||
"OPTIONS and NEW GAME's difficulty chain as MEASURED destinations,",
|
||||
"blocked only because 'there is no screen file to go to'.",
|
||||
"",
|
||||
"GP_OPTIONS ADDED 2026-09-03, and deliberately alone. The probe",
|
||||
"(crates/sylpheed-export/examples/probe_archives.rs) finds screen builds",
|
||||
"in 24 archives with the EXISTING detector -- GP_OPTIONS 14,",
|
||||
"GP_SAVE_LOAD 18, GP_DIALOG 105, GP_TUTORIAL 2. Adding all four at once",
|
||||
"would land 139 new screens together and make any regression",
|
||||
"unattributable, so this takes the smallest archive first.",
|
||||
"",
|
||||
"\u26a0\ufe0f is_build() PARSING IS NOT RENDERING. It says the record is a build,",
|
||||
"not that its sprites resolve or that anyone has identified the screen.",
|
||||
"Unnamed builds export as build_NN by entry index. Expect names to be",
|
||||
"wrong-looking until someone drives the game to them; that is a naming",
|
||||
"gap, not a decode failure.",
|
||||
"",
|
||||
"GP_SAVE_LOAD ADDED 2026-09-03, again alone. 18 builds. It is main_menu",
|
||||
"ptbtn02 (LOAD GAME)'s destination, recorded in authored/flow.json as a",
|
||||
"MEASURED destination blocked only by 'not a GP_TITLE build'. It may also",
|
||||
"hold SELECT DATA, the second screen of the NEW GAME chain, but that is a",
|
||||
"guess from the name until the screens are rendered and read.",
|
||||
"",
|
||||
"\u26a0\ufe0f OUT OF SCOPE ON PURPOSE: GP_HANGAR_ARSENAL (390 builds), the",
|
||||
"GP_MAIN_GAME_* set and the rest of the gameplay archives. MISSION",
|
||||
"section 7 scopes gameplay out, and a screen that parses is not a screen",
|
||||
"this milestone wants."
|
||||
],
|
||||
"archives": {
|
||||
"dat/GP_TITLE.pak": {
|
||||
"2": {
|
||||
"name": "press_start",
|
||||
"why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture."
|
||||
"why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"3": {
|
||||
"name": "press_start_jp",
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need."
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"4": {
|
||||
"name": "title",
|
||||
"why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture."
|
||||
"why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"5": {
|
||||
"name": "main_menu",
|
||||
"why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English. Measured against a live capture. (An earlier reading called 8 a submenu and was withdrawn -- 8 is the Japanese main menu.)"
|
||||
"why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English. Measured against a live capture. (An earlier reading called 8 a submenu and was withdrawn -- 8 is the Japanese main menu.) \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"6": {
|
||||
"name": "extras",
|
||||
"why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture."
|
||||
"why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"7": {
|
||||
"name": "title_jp",
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 4."
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 4. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"8": {
|
||||
"name": "main_menu_jp",
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 5."
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 5. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"9": {
|
||||
"name": "extras_jp",
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 6."
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 6. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"10": {
|
||||
"name": "publisher_logo",
|
||||
"why": "The SQUARE ENIX PUBLISHER wordmark -- the FIRST thing the boot sequence shows, before the developer logos. Measured by the RE agent 2026-08-29, render grid at docs/re/captures/title-builds/splash-both-halves-rendered.png. Entries 10/13 are region twins distinguished by the trademark glyph; 10 carries the (TM)."
|
||||
"why": "The SQUARE ENIX PUBLISHER wordmark -- the FIRST thing the boot sequence shows, before the developer logos. Measured by the RE agent 2026-08-29, render grid at docs/re/captures/title-builds/splash-both-halves-rendered.png. Entries 10/13 are region twins distinguished by the trademark glyph; 10 carries the (TM). \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"13": {
|
||||
"name": "publisher_logo_r",
|
||||
"why": "The region twin of entry 10, carrying (R) where 10 carries (TM). Named so it is not mistaken for a second screen the boot path needs."
|
||||
"why": "The region twin of entry 10, carrying (R) where 10 carries (TM). Named so it is not mistaken for a second screen the boot path needs. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"11": {
|
||||
"name": "developer_logos",
|
||||
"why": "The GAME ARTS / SETA / studio anima logos -- the developer splash, shown after the publisher wordmark. HANDOFF Q2 and the RE agent's 2026-08-29 render grid; draws 7/7 elements."
|
||||
"why": "The GAME ARTS / SETA / studio anima logos -- the developer splash, shown after the publisher wordmark. HANDOFF Q2 and the RE agent's 2026-08-29 render grid; draws 7/7 elements. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"14": {
|
||||
"name": "developer_logos_r",
|
||||
"why": "The region twin of entry 11, as 13 is to 10."
|
||||
"why": "The region twin of entry 11, as 13 is to 10. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
}
|
||||
},
|
||||
"dat/GP_OPTIONS.pak": {
|
||||
"3": {
|
||||
"name": "sound_settings",
|
||||
"why": "SOUND SETTINGS -- Music/Movie/Voice/SFX Volume. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
|
||||
},
|
||||
"4": {
|
||||
"name": "control_settings",
|
||||
"why": "CONTROL SETTINGS -- Control Type, Throttle, sensitivities, Vibration. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
|
||||
},
|
||||
"5": {
|
||||
"name": "sound_settings_jp",
|
||||
"why": "\u30b5\u30a6\u30f3\u30c9\u8a2d\u5b9a, the JP pair of entry 3. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
|
||||
},
|
||||
"6": {
|
||||
"name": "screen_settings",
|
||||
"why": "Gamma Correction with R/G/B and a NEXT PAGE affordance. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
|
||||
},
|
||||
"7": {
|
||||
"name": "screen_settings_page2",
|
||||
"why": "White Level / Black Level Adjust, PREVIOUS PAGE. Page 2 of entry 6. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
|
||||
},
|
||||
"8": {
|
||||
"name": "control_settings_jp",
|
||||
"why": "\u64cd\u4f5c\u8a2d\u5b9a, the JP pair of entry 4. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
|
||||
},
|
||||
"9": {
|
||||
"name": "screen_settings_jp",
|
||||
"why": "\u30ac\u30f3\u30de\u88dc\u6b63\u30ec\u30d9\u30eb, the JP pair of entry 6. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
|
||||
},
|
||||
"10": {
|
||||
"name": "screen_settings_page2_jp",
|
||||
"why": "\u767d\u30ec\u30d9\u30eb/\u9ed2\u30ec\u30d9\u30eb\u8abf\u6574, the JP pair of entry 7. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
|
||||
},
|
||||
"16": {
|
||||
"name": "game_settings",
|
||||
"why": "GAME SETTINGS -- Auto-Save, View Point, Radio Log, Subtitles. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
|
||||
},
|
||||
"18": {
|
||||
"name": "game_settings_jp",
|
||||
"why": "\u30b2\u30fc\u30e0\u8a2d\u5b9a, the JP pair of entry 16. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
|
||||
},
|
||||
"19": {
|
||||
"name": "options",
|
||||
"why": "\ud83d\udd34 THE OPTIONS ROOT. Rows: GAME SETTINGS, CONTROL SETTINGS, SOUND SETTINGS, SCREEN SETTINGS, BACK -- the four screens named here plus a back row. This is main_menu ptbtn04's destination. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
|
||||
},
|
||||
"20": {
|
||||
"name": "control_customize",
|
||||
"why": "CUSTOMIZE -- per-action key remapping, reached from CONTROL SETTINGS. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
|
||||
},
|
||||
"21": {
|
||||
"name": "options_jp",
|
||||
"why": "The JP OPTIONS root, pair of entry 19. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
|
||||
},
|
||||
"22": {
|
||||
"name": "control_customize_jp",
|
||||
"why": "\u30ad\u30fc\u30ab\u30b9\u30bf\u30de\u30a4\u30ba, the JP pair of entry 20. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -75,19 +171,19 @@
|
||||
"dat/GP_TITLE.pak": {
|
||||
"10": {
|
||||
"name": "publisher_logo",
|
||||
"why": "LOCATED BY ENTRY INDEX, not by a rule. These four bundles declare their sprites directly and have no .rat layout child, so `is_build` cannot see them -- and the RE agent established that NO content rule can: design size fails (every extra composable bundle sampled is 1280x720, the same as every screen) and element count fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD while these are 3 and 7 -- the ranges overlap). Safe here and not in general: in GP_TITLE the widened set adds exactly these four and all four are real screens, zero fragments."
|
||||
"why": "LOCATED BY ENTRY INDEX, not by a rule. These four bundles declare their sprites directly and have no .rat layout child, so `is_build` cannot see them -- and the RE agent established that NO content rule can: design size fails (every extra composable bundle sampled is 1280x720, the same as every screen) and element count fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD while these are 3 and 7 -- the ranges overlap). Safe here and not in general: in GP_TITLE the widened set adds exactly these four and all four are real screens, zero fragments. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"11": {
|
||||
"name": "developer_logos",
|
||||
"why": "As entry 10: located by index because no content rule distinguishes a splash from a fragment. 7 elements, all drawn."
|
||||
"why": "As entry 10: located by index because no content rule distinguishes a splash from a fragment. 7 elements, all drawn. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"13": {
|
||||
"name": "publisher_logo_r",
|
||||
"why": "As entry 10, region twin."
|
||||
"why": "As entry 10, region twin. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"14": {
|
||||
"name": "developer_logos_r",
|
||||
"why": "As entry 11, region twin."
|
||||
"why": "As entry 11, region twin. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,36 +195,6 @@ enum ScreenCommands {
|
||||
/// `--build`**, which is why it is a flag and not the default.
|
||||
#[arg(long)]
|
||||
all: bool,
|
||||
/// Pose every element at this KEYFRAME TIME instead of at its resting
|
||||
/// pose (60 units = 1 second). The resting pose is each element's last
|
||||
/// *hold* keyframe, picked independently of every other element, so it is
|
||||
/// not the screen at any one moment: it omits anything still moving (the
|
||||
/// title's light sweeps hold off the right edge) and freezes a transient
|
||||
/// at its PEAK (the title's five two-frame flashes burn forever).
|
||||
/// ⚠️ This help used to end "Prefer `--settle`". That is WITHDRAWN and was
|
||||
/// never measured: scored against a live capture of the JP title, settle
|
||||
/// gives RMSE 40.210 and rest 41.690 — a margin of 1.48 against that
|
||||
/// instrument's own noise floor of 1.2, which is NOT decisive. `--settle`
|
||||
/// also has its own failure mode (25.5 % of elements are mid-ramp at their
|
||||
/// screen's settle instant). Neither is established as better; pick by what
|
||||
/// you are measuring. See `docs/re/structures/ui-resting-pose.md`.
|
||||
#[arg(long, conflicts_with = "settle")]
|
||||
at: Option<u32>,
|
||||
/// Pose every element at the instant the screen is SETTLED, derived from
|
||||
/// the disc: the midpoint of the longest interval containing no keyframe
|
||||
/// of any element. Prints the window it used, whose width is how much the
|
||||
/// midpoint is worth — a narrow one means the bundle never settles.
|
||||
/// ⚠️ That is **38 % of the screen builds this command renders** (185 of
|
||||
/// 491 carrying two or more keyframe times) and 39 % of the wider set
|
||||
/// `--all` admits (862 of 2 211), mostly `loop*` fragments. This help used
|
||||
/// to say "42 % of them" without saying of WHAT: 42 % was 731/1 758 over
|
||||
/// composable bundles, computed before the keyframe record-layout fix,
|
||||
/// which times a group's final pose and so admits ~450 bundles that
|
||||
/// previously had only one timed keyframe. ⚠️ NOT established as better
|
||||
/// than the resting pose — see the note on `--at`. See
|
||||
/// `docs/re/structures/ui-settle-time.md`.
|
||||
#[arg(long)]
|
||||
settle: bool,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -383,10 +353,8 @@ async fn main() -> Result<()> {
|
||||
black,
|
||||
all,
|
||||
primitives,
|
||||
at,
|
||||
settle,
|
||||
} => cmd_screen_render(
|
||||
&pak, &output, build, focus, animated, black, all, primitives, at, settle,
|
||||
&pak, &output, build, focus, animated, black, all, primitives,
|
||||
),
|
||||
},
|
||||
Commands::Save { cmd } => match cmd {
|
||||
@@ -613,40 +581,12 @@ fn cmd_screen_render(
|
||||
black: bool,
|
||||
all: bool,
|
||||
primitives: bool,
|
||||
at: Option<u32>,
|
||||
settle: bool,
|
||||
) -> Result<()> {
|
||||
use sylpheed_formats::ui_layout::{self, ComposeOptions};
|
||||
let builds = screen_builds(pak, all)?;
|
||||
let idx = pick_build(&builds, want)?;
|
||||
let bytes = &builds[idx].1;
|
||||
let b = ui_layout::parse_build(bytes).context("build did not parse")?;
|
||||
let at = if settle {
|
||||
match (b.settle_window(), b.settle_time()) {
|
||||
(Some((lo, hi)), Some(t)) => {
|
||||
// Report the width, not just the answer. A 4-unit window and a
|
||||
// 190-unit one give the same kind of number and mean entirely
|
||||
// different things.
|
||||
println!(
|
||||
"settle window [{lo}, {hi}] = {} units ({:.2} s) -> posing at t={t}{}",
|
||||
hi - lo,
|
||||
(hi - lo) as f64 / 60.0,
|
||||
if hi - lo < 30 {
|
||||
" ⚠️ narrow — this bundle may never settle"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
Some(t)
|
||||
}
|
||||
_ => {
|
||||
println!("no settle window (fewer than two distinct keyframe times) — using rest()");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
at
|
||||
};
|
||||
let screen = ui_layout::compose(
|
||||
&b,
|
||||
bytes,
|
||||
@@ -659,7 +599,6 @@ fn cmd_screen_render(
|
||||
ComposeOptions::default().backdrop
|
||||
},
|
||||
include_primitives: primitives,
|
||||
at,
|
||||
},
|
||||
None,
|
||||
);
|
||||
@@ -682,35 +621,14 @@ fn cmd_screen_render(
|
||||
if !screen.missing.is_empty() {
|
||||
println!(" sprites that did not resolve/decode: {:?}", screen.missing);
|
||||
}
|
||||
// 🔴 Report the INDEX, the KIND and WHY, not just the name. A kind-`0x4`
|
||||
// ghost instance carries its template's name, so a bare name list shows
|
||||
// `ptlogo1.t32` twice and reads as "the logo is missing" when what is
|
||||
// skipped is two motion-trail ghosts sitting at alpha 0 off-screen. That
|
||||
// misreading cost this project a wrong finding sent to another agent.
|
||||
let undrawn: Vec<String> = b
|
||||
let undrawn: Vec<&str> = b
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|e| !screen.drawn.contains(&e.index))
|
||||
.map(|e| {
|
||||
let why = if e.name.ends_with(".prm") {
|
||||
"untextured primitive, needs --primitives"
|
||||
} else if e.name.ends_with(".rat") {
|
||||
"animation, needs --animated"
|
||||
} else if e.kind == 0x4 {
|
||||
"kind 0x4 ghost instance"
|
||||
} else if e.rest().map(|k| k.fade >> 24) == Some(0) {
|
||||
"transparent at its pose"
|
||||
} else {
|
||||
"no reason established"
|
||||
};
|
||||
format!("[{}] {} ({why})", e.index, e.name)
|
||||
})
|
||||
.map(|e| e.name.as_str())
|
||||
.collect();
|
||||
if !undrawn.is_empty() {
|
||||
println!(" not drawn ({}):", undrawn.len());
|
||||
for u in &undrawn {
|
||||
println!(" {u}");
|
||||
}
|
||||
println!(" not drawn ({}): {undrawn:?}", undrawn.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -826,16 +744,8 @@ fn cmd_audio_info(file: &Path) -> Result<()> {
|
||||
println!(" Channels : {}", opt(info.channels.map(|c| c.to_string())));
|
||||
println!(" Sample rate: {}", opt(info.sample_rate.map(|r| format!("{r} Hz"))));
|
||||
println!(" Bit depth : {}", opt(info.bits_per_sample.map(|b| format!("{b}-bit"))));
|
||||
if let Some(b) = info.avg_bytes_per_sec {
|
||||
println!(" Byte rate : {} B/s (declared)", b.to_string().yellow());
|
||||
}
|
||||
if let Some(d) = info.duration_secs {
|
||||
let how = if info.codec == sylpheed_formats::AudioCodec::Xma {
|
||||
" (from the declared byte rate, not decoded)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
println!(" Duration : {d:.2} s{how}");
|
||||
println!(" Duration : {d:.2} s");
|
||||
}
|
||||
if let Some(p) = info.xma_packets {
|
||||
println!(" XMA packets: {} (2048 B each)", p.to_string().yellow());
|
||||
|
||||
42
crates/sylpheed-export/examples/probe_archives.rs
Normal file
42
crates/sylpheed-export/examples/probe_archives.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! Which disc archives contain UI screen builds?
|
||||
//!
|
||||
//! The exporter reads `dat/GP_TITLE.pak` and nothing else, so four of the five
|
||||
//! main-menu destinations have no screen file to go to: `authored/flow.json`
|
||||
//! records LOAD GAME as `GP_SAVE_LOAD`, OPTIONS as `GP_OPTIONS`, and NEW GAME's
|
||||
//! chain as `DLG_SELECT_DIFFICULTY` -> `SELECT DATA`, all measured destinations
|
||||
//! that this export cannot reach.
|
||||
//!
|
||||
//! This asks the cheap question before anyone refactors the exporter: does the
|
||||
//! EXISTING build detector find anything in those archives? It changes nothing
|
||||
//! and writes nothing.
|
||||
//!
|
||||
//! cargo run --release -p sylpheed-export --example probe_archives
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut names: Vec<String> = std::fs::read_dir(format!("{disc}/dat"))?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.ends_with(".pak"))
|
||||
.collect();
|
||||
names.sort();
|
||||
println!("{:<32} {:>7} {:>8}", "archive", "entries", "builds");
|
||||
for n in names {
|
||||
let path = format!("{disc}/dat/{n}");
|
||||
let Ok(ar) = PakArchive::open(&path) else {
|
||||
println!("{n:<32} {:>7} {:>8}", "-", "open failed");
|
||||
continue;
|
||||
};
|
||||
let total = ar.entries().len();
|
||||
let builds = ar
|
||||
.entries()
|
||||
.iter()
|
||||
.filter(|e| ar.read(e).map(|b| ui_layout::is_build(&b)).unwrap_or(false))
|
||||
.count();
|
||||
if builds > 0 || n.contains("OPTIONS") || n.contains("SAVE") || n.contains("DIALOG") {
|
||||
println!("{n:<32} {total:>7} {builds:>8}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
//!
|
||||
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
|
||||
|
||||
mod audio;
|
||||
mod check;
|
||||
mod video;
|
||||
mod screen;
|
||||
@@ -20,7 +21,7 @@ use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use sylpheed_formats::{media, pak::PakArchive, ui_layout};
|
||||
|
||||
/// The revision of `sylpheed-formats` this exporter is pinned to, recorded in
|
||||
/// every file it writes. Keep in step with `Cargo.toml` — it is what makes an
|
||||
@@ -76,6 +77,48 @@ struct ManifestVideo {
|
||||
/// dislikes the quality re-runs one line rather than reverse-engineering it.
|
||||
command: String,
|
||||
why: &'static str,
|
||||
/// What the runtime should have played, so it can report what it did.
|
||||
/// See `video::Transcoded::duration_s` — the port measured its player
|
||||
/// presenting 28–47 % of a stream's frames, and seconds alone hide that.
|
||||
duration_s: f64,
|
||||
fps: f64,
|
||||
}
|
||||
|
||||
/// One exported audio file. Carries the same provenance a video does, plus the
|
||||
/// measured peak and duration: silence and clipping are the two audio failures
|
||||
/// that pass every check that is not looking for them.
|
||||
#[derive(Serialize)]
|
||||
struct ManifestAudio {
|
||||
/// `se` or `bgm`. The runtime dispatches on it, so it is a field rather
|
||||
/// than a prefix on `name` that a consumer would have to parse.
|
||||
kind: &'static str,
|
||||
name: String,
|
||||
file: String,
|
||||
command: String,
|
||||
why: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
peak_dbfs: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
duration_s: Option<f32>,
|
||||
/// 🔴 One line saying what this asset is KNOWN to be missing, for the
|
||||
/// runtime to announce. Absent means nothing is known to be missing --
|
||||
/// never that the asset was checked and is complete.
|
||||
///
|
||||
/// It exists because the export could already say this and the RUNTIME
|
||||
/// could not. `why` carries the full account, but it is a paragraph aimed
|
||||
/// at a reader of the manifest; a player hears clean dialogue and has no
|
||||
/// way to learn that a stream is absent from it. This port already
|
||||
/// announces the two measured screens NEW GAME jumps over, on the principle
|
||||
/// that a gap is announced before it is opened. Audio had no equivalent.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
incomplete: Option<String>,
|
||||
/// The game's own cue identifier where one is a NAME MATCH. Absent means
|
||||
/// nobody has claimed one -- never that the binding is unknown.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name_match: Option<String>,
|
||||
/// What the runtime does at the end of the file, where that was authored.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
loop_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -88,6 +131,8 @@ struct Manifest {
|
||||
screens: Vec<ManifestScreen>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
videos: Vec<ManifestVideo>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
audio: Vec<ManifestAudio>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -163,6 +208,54 @@ fn load_also_export(authored: &Path) -> Result<AlsoExport> {
|
||||
/// exactly four bundles and all four are real screens, with zero fragments. In
|
||||
/// another archive it would not be, which is why this is an allow-list and not
|
||||
/// a widened predicate.
|
||||
/// Which archives the export reads, from `authored/screen_names.json`
|
||||
/// `export_archives`.
|
||||
///
|
||||
/// 🔴 THIS WAS ONE HARDCODED CONSTANT AND IT COST FOUR MENU DESTINATIONS.
|
||||
/// `authored/flow.json` records LOAD GAME, TUTORIAL, OPTIONS and NEW GAME's
|
||||
/// difficulty chain as MEASURED destinations that are `blocked` because "not a
|
||||
/// GP_TITLE build, so there is no screen file to go to". The blocker was never
|
||||
/// the disc or the reader -- `examples/probe_archives.rs` finds screen builds in
|
||||
/// 24 archives using the EXISTING detector. It was this line.
|
||||
///
|
||||
/// Absent from the authored file, it stays exactly what it was, so an old
|
||||
/// `authored/` tree exports what it always did.
|
||||
fn load_export_archives(authored: &Path) -> Result<Vec<String>> {
|
||||
let path = authored.join("screen_names.json");
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
return Ok(vec!["dat/GP_TITLE.pak".into()]);
|
||||
};
|
||||
let v: serde_json::Value = serde_json::from_str(&text)
|
||||
.with_context(|| format!("parse {}", path.display()))?;
|
||||
match v.get("export_archives").and_then(|a| a.as_array()) {
|
||||
None => Ok(vec!["dat/GP_TITLE.pak".into()]),
|
||||
Some(list) => Ok(list
|
||||
.iter()
|
||||
.filter_map(|e| e.as_str().map(str::to_owned))
|
||||
.collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sprite subdirectory for an archive: `dat/GP_OPTIONS.pak` -> `options`.
|
||||
///
|
||||
/// ⚠️ NOT cosmetic. Sprites are written to `sprites/<group>/<screen>/`, so two
|
||||
/// archives sharing a group would collide by screen name -- and unnamed builds
|
||||
/// are named `build_NN` by ENTRY INDEX, which restarts at 0 in every archive.
|
||||
/// `GP_TITLE` keeps its historical `title` so no existing path moves.
|
||||
fn group_for(archive: &str) -> &'static str {
|
||||
match archive {
|
||||
"dat/GP_TITLE.pak" => "title",
|
||||
"dat/GP_OPTIONS.pak" => "options",
|
||||
"dat/GP_SAVE_LOAD.pak" => "save_load",
|
||||
"dat/GP_TUTORIAL.pak" => "tutorial",
|
||||
"dat/GP_DIALOG.pak" => "dialog",
|
||||
// Deliberately not derived from the filename: a new archive should be a
|
||||
// decision someone made, not a directory that appears because a string
|
||||
// parsed. An unmapped archive is rejected below.
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn screen_builds(ar: &PakArchive, also: Option<&std::collections::BTreeMap<String, NameEntry>>)
|
||||
-> Vec<(usize, Vec<u8>)>
|
||||
{
|
||||
@@ -195,86 +288,275 @@ fn main() -> Result<()> {
|
||||
fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
let names = load_names(authored_dir)?;
|
||||
|
||||
// Built up as the export runs. A warning is a thing a CONSUMER of the tree
|
||||
// has to know about; it is not an error, and it is not a log line, because
|
||||
// the person who needs it reads `manifest.json` and never sees stdout.
|
||||
let mut warnings: Vec<String> = vec![
|
||||
String::new(), // replaced below once the archive list is known
|
||||
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
|
||||
layout child, so `is_build` cannot see them and no content rule can: element \
|
||||
count and design size both overlap with two-element fragments in other archives. \
|
||||
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
|
||||
which is a locator and not a claim -- see each one's name_why."
|
||||
.into(),
|
||||
];
|
||||
|
||||
// Derived output is regenerated wholesale: clear it, so a screen that stops
|
||||
// being exported stops existing rather than lingering as a stale file that
|
||||
// still validates.
|
||||
//
|
||||
// 🔴 EXCEPT `video/`, and leaving it out was a bug that hid in plain sight.
|
||||
// `video::transcode` has always carried a cache -- it writes a `.cmd`
|
||||
// sidecar with the exact command, the source size and the channel count, and
|
||||
// skips the encode when all three still match. Its own doc comment says
|
||||
// "without it every re-export pays ~4 minutes to produce a byte-identical
|
||||
// file". **This wipe deleted the sidecar and the output immediately before
|
||||
// the check, so the cache had never hit once.** Six exports in one session
|
||||
// paid ~48 minutes of Theora to produce five byte-identical files, and
|
||||
// nothing reported it: the cache is silent when it works and silent when it
|
||||
// does not.
|
||||
//
|
||||
// The wholesale guarantee is kept rather than weakened -- everything else is
|
||||
// still cleared outright, and `prune_videos` below deletes any file in
|
||||
// `video/` that this run did not claim, so a movie that stops being exported
|
||||
// still stops existing.
|
||||
if out.exists() {
|
||||
std::fs::remove_dir_all(&out).context("clear the output tree")?;
|
||||
for entry in std::fs::read_dir(&out).context("clear the output tree")? {
|
||||
let entry = entry?;
|
||||
if entry.file_name() == "video" {
|
||||
continue;
|
||||
}
|
||||
if entry.file_type()?.is_dir() {
|
||||
std::fs::remove_dir_all(entry.path())
|
||||
} else {
|
||||
std::fs::remove_file(entry.path())
|
||||
}
|
||||
.with_context(|| format!("clear {}", entry.path().display()))?;
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(&out)?;
|
||||
|
||||
let archive = "dat/GP_TITLE.pak";
|
||||
let pak = disc.join(archive);
|
||||
let ar = PakArchive::open(&pak).with_context(|| format!("open {}", pak.display()))?;
|
||||
let also = load_also_export(authored_dir)?;
|
||||
let archive_also = also.get(archive);
|
||||
let builds = screen_builds(&ar, archive_also);
|
||||
println!("{archive}: {} screen build(s)", builds.len());
|
||||
|
||||
let archive_names = names.get(archive);
|
||||
let archives = load_export_archives(authored_dir)?;
|
||||
warnings[0] = format!(
|
||||
"Screen builds from {} only ({}). Other archives on the disc also contain UI \
|
||||
builds and are not exported. Only the two movies MISSION section 6 puts in scope.",
|
||||
archives.len(),
|
||||
archives.join(", ")
|
||||
);
|
||||
let mut screens = Vec::new();
|
||||
for (build_idx, (entry, bytes)) in builds.iter().enumerate() {
|
||||
// Keyed by ENTRY, not by the ordinal: widening the enumeration to reach
|
||||
// the splash renumbers ordinals, and a name that moves when the rule
|
||||
// changes is not a name.
|
||||
let key = entry.to_string();
|
||||
let named = archive_names
|
||||
.and_then(|m| m.get(&key))
|
||||
.or_else(|| archive_also.and_then(|m| m.get(&key)));
|
||||
let (name, name_source, why) = match named {
|
||||
Some(e) => (e.name.clone(), "authored", e.why.clone()),
|
||||
// Nobody has identified this build. Emit a stable synthetic id and
|
||||
// say in the file that the name is not a recovered one.
|
||||
None => (format!("build_{entry:02}"), "index", None),
|
||||
};
|
||||
let ex = screen::export_build(
|
||||
&out,
|
||||
archive,
|
||||
*entry,
|
||||
build_idx,
|
||||
bytes,
|
||||
&name,
|
||||
name_source,
|
||||
why,
|
||||
"title",
|
||||
EXPORTER,
|
||||
FORMATS_REV,
|
||||
)
|
||||
.with_context(|| format!("export build {build_idx} of {archive}"))?;
|
||||
println!(
|
||||
" [{build_idx}] entry {entry:<3} -> {} ({} sprites{})",
|
||||
ex.json_path,
|
||||
ex.sprites,
|
||||
if ex.missing.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", {} missing", ex.missing.len())
|
||||
}
|
||||
);
|
||||
screens.push(ManifestScreen {
|
||||
name: ex.name,
|
||||
file: ex.json_path,
|
||||
sprites: ex.sprites,
|
||||
missing_sprites: ex.missing,
|
||||
});
|
||||
for archive in archives.iter().map(String::as_str) {
|
||||
let group = group_for(archive);
|
||||
if group.is_empty() {
|
||||
anyhow::bail!(
|
||||
"authored/screen_names.json export_archives lists {archive}, which has no \
|
||||
sprite group in group_for(). Add one deliberately -- deriving it from the \
|
||||
filename would let a typo create a directory."
|
||||
);
|
||||
}
|
||||
let pak = disc.join(archive);
|
||||
let ar = PakArchive::open(&pak).with_context(|| format!("open {}", pak.display()))?;
|
||||
let archive_also = also.get(archive);
|
||||
let builds = screen_builds(&ar, archive_also);
|
||||
println!("{archive}: {} screen build(s) -> sprites/{group}/", builds.len());
|
||||
|
||||
let archive_names = names.get(archive);
|
||||
for (build_idx, (entry, bytes)) in builds.iter().enumerate() {
|
||||
// Keyed by ENTRY, not by the ordinal: widening the enumeration to reach
|
||||
// the splash renumbers ordinals, and a name that moves when the rule
|
||||
// changes is not a name.
|
||||
let key = entry.to_string();
|
||||
let named = archive_names
|
||||
.and_then(|m| m.get(&key))
|
||||
.or_else(|| archive_also.and_then(|m| m.get(&key)));
|
||||
let (name, name_source, why) = match named {
|
||||
Some(e) => (e.name.clone(), "authored", e.why.clone()),
|
||||
// Nobody has identified this build. Emit a stable synthetic id and
|
||||
// say in the file that the name is not a recovered one.
|
||||
None => (format!("build_{entry:02}"), "index", None),
|
||||
};
|
||||
let ex = screen::export_build(
|
||||
&out,
|
||||
archive,
|
||||
*entry,
|
||||
build_idx,
|
||||
bytes,
|
||||
&name,
|
||||
name_source,
|
||||
why,
|
||||
group,
|
||||
EXPORTER,
|
||||
FORMATS_REV,
|
||||
)
|
||||
.with_context(|| format!("export build {build_idx} of {archive}"))?;
|
||||
println!(
|
||||
" [{build_idx}] entry {entry:<3} -> {} ({} sprites{})",
|
||||
ex.json_path,
|
||||
ex.sprites,
|
||||
if ex.missing.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", {} missing", ex.missing.len())
|
||||
}
|
||||
);
|
||||
screens.push(ManifestScreen {
|
||||
name: ex.name,
|
||||
file: ex.json_path,
|
||||
sprites: ex.sprites,
|
||||
missing_sprites: ex.missing,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// MISSION §6: the boot intro and the one new-game intro only.
|
||||
let mut videos = Vec::new();
|
||||
let mut movie_lengths: Vec<(&'static str, Option<f32>)> = Vec::new();
|
||||
// 🔴 The export deviates from a HUMAN decision, and until this warning
|
||||
// existed nobody could tell. MISSION §6 pins the 5.1 fold; `video.rs` ships
|
||||
// that matrix scaled by 0.4142, i.e. 7.65 dB quieter. The deviation is
|
||||
// justified for one of the two movies and over-broad for the other, and
|
||||
// which of the three options to take is not the exporter's call -- so it is
|
||||
// reported on every run rather than left in a doc comment nobody opens.
|
||||
if video::MOVIES.iter().any(|m| disc.join(m.src).exists()) {
|
||||
warnings.push(
|
||||
"video/*.ogv: the 5.1->stereo fold is NOT the matrix MISSION §6 pins. §6 fixes it at FL = 1.0*FL + 0.707*FC + 0.707*BL (a human decision, 2026-08-29); this export ships that matrix scaled by 0.4142 -- same weighting, 7.65 dB quieter. Measured over the whole of both movies, float-decoded so nothing is pre-clamped: under the PINNED matrix ADV peaks at +4.26 dBFS with 4406 samples at or over full scale (1874 more than 1 dB over, longest clamped run 0.333 ms), while S00A peaks at -1.34 dBFS and never clips. So the pin overloads ADV and this constant is over-broad for S00A; the smallest single scalar under which neither clamps is 1/1.6339 = 0.612. NOT changed on the exporter's own authority -- the level of a mix is what §6 reserves to a human. See docs/port/DECISIONS.md."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
for m in video::MOVIES {
|
||||
match video::transcode(disc, out, m)? {
|
||||
Some(t) => {
|
||||
println!(" video {} -> {}", m.src, t.file);
|
||||
movie_lengths.push((m.stem, audio::probe_duration(&out.join(&t.file))));
|
||||
videos.push(ManifestVideo {
|
||||
name: t.name,
|
||||
file: t.file,
|
||||
command: t.command,
|
||||
why: t.why,
|
||||
duration_s: t.duration_s,
|
||||
fps: t.fps,
|
||||
});
|
||||
}
|
||||
None => println!(" video {} not on this disc -- skipped", m.src),
|
||||
}
|
||||
}
|
||||
prune_videos(out, &videos)?;
|
||||
|
||||
// P6. Both tables are AUTHORED, for two different reasons -- the cue offsets
|
||||
// because they were measured off the running game and are on the disc in no
|
||||
// findable form, the BGM choice because HANDOFF Q10 is a negative and
|
||||
// nothing states which track a menu plays. See `authored/audio.json`.
|
||||
let mut audio = Vec::new();
|
||||
let audio_cfg = audio::load(authored_dir)?;
|
||||
match &audio_cfg {
|
||||
None => println!(" no authored/audio.json -- no audio exported"),
|
||||
Some(cfg) => {
|
||||
let source = media::DirectorySource::new(disc);
|
||||
for a in audio::export_cues(&source, out, &cfg.se)? {
|
||||
println!(
|
||||
" se {:<8} -> {} ({})",
|
||||
a.name,
|
||||
a.file,
|
||||
describe(&a)
|
||||
);
|
||||
audio.push(ManifestAudio::from(a));
|
||||
}
|
||||
for (role, spec) in &cfg.bgm {
|
||||
match audio::export_bgm(&source, out, role, spec)? {
|
||||
Some(a) => {
|
||||
println!(
|
||||
" bgm {:<8} -> {} ({}, bank {}, {} sub-wave(s))",
|
||||
a.name,
|
||||
a.file,
|
||||
describe(&a),
|
||||
spec.bank,
|
||||
a.sub_waves
|
||||
);
|
||||
// HANDOFF Q10's census is "exactly two waves of
|
||||
// identical duration, 32/32 banks on the disc". When
|
||||
// `media` hands back a different number, SAY SO -- the
|
||||
// port does not get to decide that one of them is not a
|
||||
// stem, and silently summing an extra region into the
|
||||
// music is precisely the media-assembly mistake MISSION
|
||||
// section 2 names. The decoder's answer is what ships;
|
||||
// the disagreement is what gets reported.
|
||||
if a.sub_waves != 2 {
|
||||
warnings.push(format!(
|
||||
"audio/bgm/{role}.ogg: sylpheed_formats::media::sound_bank_riffs \
|
||||
returned {} sub-wave(s) for `{}`, but HANDOFF Q10's bank census \
|
||||
says a music bank is EXACTLY TWO waves of identical duration \
|
||||
(32/32 banks). All {} are summed, because choosing which to drop \
|
||||
is a decoding question and this exporter does not answer those. \
|
||||
See docs/port/BLOCKED.md.",
|
||||
a.sub_waves, spec.bank, a.sub_waves
|
||||
));
|
||||
}
|
||||
audio.push(ManifestAudio::from(a));
|
||||
}
|
||||
// Not an error: the authored bank may simply not be on this
|
||||
// disc, and the export of everything else is still good.
|
||||
None => warnings.push(format!(
|
||||
"authored/audio.json bgm.{role} names bank `{}`, which is not in \
|
||||
this disc's sound.pak -- no BGM exported for that role.",
|
||||
spec.bank
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The cutscene voices are DERIVED, not authored, so this runs outside the
|
||||
// `authored/audio.json` block above: the binding comes off the disc (the
|
||||
// movie manifest in `tables.pak`), and an export with no authored audio
|
||||
// should still carry the dialogue for the movies it ships.
|
||||
//
|
||||
// A movie that resolves to no region is genuinely unvoiced and gets a
|
||||
// warning rather than a substitute -- for both movies in scope this port
|
||||
// expects a region, so a warning here is a real signal and not noise.
|
||||
{
|
||||
let source = media::DirectorySource::new(disc);
|
||||
for (stem, len) in &movie_lengths {
|
||||
// The presentation choice is AUTHORED and this block runs even when
|
||||
// there is no `authored/audio.json` -- the voice binding is decoded,
|
||||
// so the dialogue exports either way and only the choice defaults.
|
||||
let want = audio_cfg.as_ref().map(|c| c.voice).unwrap_or_default();
|
||||
let weights = audio_cfg.as_ref().map(|c| c.stream_weights.clone()).unwrap_or_default();
|
||||
match audio::export_voice(&source, out, stem, *len, want, &weights)? {
|
||||
Some(a) => {
|
||||
// 🔴 A TOP-LEVEL WARNING, not just a `why` on the entry. The
|
||||
// export is known to be missing audio the game plays, and
|
||||
// the failure sounds like success: one stream decodes to
|
||||
// clean dialogue, so nobody listening finds out.
|
||||
if a.kept_waves < a.content_waves {
|
||||
warnings.push(format!(
|
||||
"{}: KNOWN INCOMPLETE. This region holds {} streams and the RUNNING \
|
||||
GAME DECODES ALL OF THEM CONCURRENTLY (Canary --xma_param_probe: \
|
||||
three XMA contexts, byte sizes matching the disc payloads exactly). \
|
||||
The export carries ONE. Nothing in the audio reveals this -- a \
|
||||
single stream is clean audible dialogue. Held rather than summed \
|
||||
because an equal-gain sum of channel pairs is not a downmix and \
|
||||
would be a second guess, not a fix. See authored/audio.json voice \
|
||||
and docs/port/BLOCKED.md.",
|
||||
a.file, a.sub_waves
|
||||
));
|
||||
}
|
||||
println!(
|
||||
" voice {:<8} -> {} ({}, {} of {} stream(s){})",
|
||||
a.name,
|
||||
a.file,
|
||||
describe(&a),
|
||||
a.kept_waves,
|
||||
a.sub_waves,
|
||||
if a.kept_waves < a.content_waves { " -- KNOWN INCOMPLETE, see warnings" } else { "" }
|
||||
);
|
||||
audio.push(ManifestAudio::from(a));
|
||||
}
|
||||
None => warnings.push(format!(
|
||||
"movie `{stem}`: the movie manifest binds it to no voice region, so no dialogue was exported. That is a real answer for an unvoiced cutscene -- nothing is substituted, because resolving an unbound movie through a shared demo line was measured to play the WRONG recording."
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let manifest = Manifest {
|
||||
format: "sylpheed.manifest/1",
|
||||
@@ -283,16 +565,8 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
disc: disc.display().to_string(),
|
||||
screens,
|
||||
videos,
|
||||
warnings: vec![
|
||||
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
|
||||
.into(),
|
||||
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
|
||||
layout child, so `is_build` cannot see them and no content rule can: element \
|
||||
count and design size both overlap with two-element fragments in other archives. \
|
||||
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
|
||||
which is a locator and not a claim -- see each one's name_why."
|
||||
.into(),
|
||||
],
|
||||
audio,
|
||||
warnings,
|
||||
};
|
||||
std::fs::write(
|
||||
out.join("manifest.json"),
|
||||
@@ -301,3 +575,81 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
println!("wrote {}/manifest.json", out.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
impl From<audio::Exported> for ManifestAudio {
|
||||
fn from(a: audio::Exported) -> Self {
|
||||
ManifestAudio {
|
||||
kind: a.kind,
|
||||
name: a.name,
|
||||
file: a.file,
|
||||
command: a.command,
|
||||
why: a.why,
|
||||
peak_dbfs: a.peak_dbfs,
|
||||
duration_s: a.duration_s,
|
||||
incomplete: (a.kept_waves < a.content_waves).then(|| {
|
||||
format!(
|
||||
"{} of {} streams. The running game decodes all {} concurrently. \
|
||||
Nothing in the audio reveals the gap -- what plays is clean dialogue. \
|
||||
WHICH streams are dropped and why differs per asset; the manifest \
|
||||
entry's `why` says, and it is not the same story twice.",
|
||||
a.kept_waves, a.sub_waves, a.sub_waves
|
||||
)
|
||||
}),
|
||||
name_match: a.name_match,
|
||||
loop_mode: a.loop_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two numbers worth reading on an audio line, in the console.
|
||||
///
|
||||
/// Printed rather than left to the manifest because the failure this catches is
|
||||
/// a SILENT file: the right duration, the right channel count, the right size,
|
||||
/// and nothing in it. `-inf dB` on stdout is the one form of that failure a
|
||||
/// person notices without being told to look.
|
||||
fn describe(a: &audio::Exported) -> String {
|
||||
let peak = match a.peak_dbfs {
|
||||
Some(p) => format!("peak {p:.1} dBFS"),
|
||||
None => "peak unmeasured".into(),
|
||||
};
|
||||
match a.duration_s {
|
||||
Some(d) => format!("{d:.3} s, {peak}"),
|
||||
None => peak,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Delete anything in `video/` this run did not produce.
|
||||
///
|
||||
/// `video/` is the one directory the wholesale wipe spares, so that the
|
||||
/// transcode cache survives to be consulted. This restores the guarantee the
|
||||
/// wipe exists for: a movie that stops being exported stops existing, rather
|
||||
/// than lingering as a file the manifest no longer lists.
|
||||
fn prune_videos(out: &Path, kept: &[ManifestVideo]) -> Result<()> {
|
||||
let dir = out.join("video");
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut keep: Vec<String> = Vec::new();
|
||||
for v in kept {
|
||||
if let Some(name) = Path::new(&v.file).file_name() {
|
||||
let name = name.to_string_lossy().into_owned();
|
||||
keep.push(name.clone());
|
||||
// The cache sidecar goes with the file it stamps.
|
||||
if let Some(stem) = Path::new(&name).file_stem() {
|
||||
keep.push(format!("{}.cmd", stem.to_string_lossy()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for entry in std::fs::read_dir(&dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if keep.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
println!(" video {name} is no longer exported -- removed");
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -17,13 +17,27 @@ use sylpheed_formats::{t8ad, ui_layout};
|
||||
///
|
||||
/// ⚠️ `0x3002` is one member of a `0x3000` family and is **not** a general
|
||||
/// button test — `GP_READY_ROOM` uses `0x3000`/`0x3004`/`0x300c`/`0x3008` and
|
||||
/// has zero `0x3002`. Every screen in this milestone is `GP_TITLE`, where the
|
||||
/// mapping is decoded; anything else exports as `unknown` with its raw kind.
|
||||
/// has zero `0x3002`. The mapping is decoded for the kinds listed; anything else
|
||||
/// exports as `unknown` with its raw kind visible.
|
||||
fn role_of(kind: u32, has_sprite: bool) -> &'static str {
|
||||
// 🔴 BIT 0 IS THE PARENT FLAG AND CARRIES NO ROLE INFORMATION. Decoded
|
||||
// disc-wide: `kind & 1` agrees with "has a parent" on 15 493 elements with
|
||||
// zero disagreements (`docs/re/ui-kind-bit0-is-has-parent.md`). So a role
|
||||
// table keyed on the raw kind splits every class in two and calls the
|
||||
// parented half `unknown` -- which is how the OPTIONS menu's rows came out
|
||||
// roleless while the exporter had already accepted them as buttons.
|
||||
//
|
||||
// ⚠️ APPLIED TO EVERY PAIR, NOT JUST THE ONE THAT FAILED. Fixing only
|
||||
// `0x3003` would have left `0x1` as `unknown` while `0x0` is `decoration`,
|
||||
// i.e. the same inconsistency one kind along -- and half-applying this
|
||||
// decode is exactly what produced the failure this is fixing.
|
||||
//
|
||||
// ⚠️ `0x73002`/`0x73003` are NOT folded in. Their `0x70000` bits are
|
||||
// undecoded, so they stay `unknown` with their raw kind visible.
|
||||
match kind {
|
||||
0x3002 => "button",
|
||||
0x10 if !has_sprite => "primitive",
|
||||
0x0 => "decoration",
|
||||
0x3002 | 0x3003 => "button",
|
||||
0x10 | 0x11 if !has_sprite => "primitive",
|
||||
0x0 | 0x1 => "decoration",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
@@ -103,6 +117,14 @@ pub struct FocusElement {
|
||||
pub id: String,
|
||||
pub declared: String,
|
||||
pub sprite: Option<String>,
|
||||
/// `true` when the game draws this sprite ADDITIVE — `T8aD +0x04` bit
|
||||
/// `0x02`, decoded. Absent when the sprite resolves to no `T8aD` header.
|
||||
///
|
||||
/// A leaf's sprite may live in the leaf's own table or in the parent
|
||||
/// bundle's, so the bit is looked up in the same two places, in the same
|
||||
/// order, that the PNG is written from.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blend_additive: Option<bool>,
|
||||
pub pivot: [u32; 2],
|
||||
pub rest: Rest,
|
||||
pub keyframes: Vec<Keyframe>,
|
||||
@@ -112,6 +134,34 @@ pub struct FocusElement {
|
||||
pub struct Focus {
|
||||
/// The `.rat` leaf this came from, e.g. `ptbtn01f.rat`.
|
||||
pub record: String,
|
||||
/// The record header's `+0x08`: **where the cycle restarts**, in keyframe
|
||||
/// units — which is not the same thing as the last keyframe's time.
|
||||
///
|
||||
/// `ptbtn00f`, the `PRESS Ⓐ` plate's glow, ramps 0→80→0 over **105** units
|
||||
/// inside a **120**-unit cycle and rests dark for the remaining 15. Deriving
|
||||
/// the period from the largest keyframe time — what the port did until now —
|
||||
/// runs it 14 % fast and deletes the dark rest entirely.
|
||||
///
|
||||
/// Decoded by the Decoder (`07e93ce`, `docs/re/structures/ui-record-loop-length.md`,
|
||||
/// delivered in HANDOFF `27938aa`) and **re-run here before adoption**, with
|
||||
/// their falsifier and their non-triviality control (⚠️ the 92.3 % below is
|
||||
/// "of records where the question is meaningful" -- 1 643 of the 1 781 with a
|
||||
/// timed keyframe. 3 311 nested records exist; the other 1 530 have no
|
||||
/// keyframe time at all, so `+0x08 == max t` is not a question there. Quoted
|
||||
/// bare until 2026-09-01, which is a population-scoped statistic reported
|
||||
/// without its population):
|
||||
/// `cargo run -p sylpheed-export --example record_loop_control`. Disc-wide
|
||||
/// 1 781 timed records, 92.3 % exact, 7.7 % hold, **0 declaring less than
|
||||
/// their own last pose**; on the eight records this port animates, seven
|
||||
/// exact and `ptbtn00f` the one hold.
|
||||
///
|
||||
/// ✅ **The port no longer owns this reading.** For one iteration `screen.rs`
|
||||
/// held its own guard and byte read, because the field was decoded in an
|
||||
/// example and a test and exposed in no public API on any ref. It is now
|
||||
/// `ui_layout::loop_length_units`, taken at `formats-pin-2026-08-30b`, and
|
||||
/// the local copy is deleted — the doc comment that promised that deletion
|
||||
/// is the only reason it did not quietly become permanent.
|
||||
pub loop_length_units: Option<u32>,
|
||||
/// Back-to-front, in the leaf's own declaration order.
|
||||
pub elements: Vec<FocusElement>,
|
||||
}
|
||||
@@ -141,6 +191,59 @@ pub struct Element {
|
||||
/// convention and a consumer may still want the bare highlight texture.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub focus: Option<Focus>,
|
||||
|
||||
/// This element's own `.rat` leaf, when its declared name is itself a
|
||||
/// record in the bundle.
|
||||
///
|
||||
/// 🔴 **DECODED DATA THE EXPORTER USED TO DROP.** `ptloop01`/`ptloop02` on
|
||||
/// the title declare scale 100 % and rotation 0 at the parent, and their
|
||||
/// leaves declare **(100, 600) at +30°** and **(100, 800) at −45°** — and
|
||||
/// the leaves *move*, x from −639 → 1521 and 1721 → −839. `ui_layout`'s own
|
||||
/// note says so: *"the rotated quads come from its two nested `.rat` leaf
|
||||
/// records, which the census never opened."* Neither did this exporter: it
|
||||
/// opened a leaf only for a FOCUS record, via `highlight_name`.
|
||||
///
|
||||
/// That omission is measurable. It is the whole of the title's 1.82 %
|
||||
/// disagreement with the oracle — the port draws two 400 px sprites upright
|
||||
/// and static at (441, 270) where the game sweeps two ~1080 and ~1440 px
|
||||
/// quads across the frame at opposite leans.
|
||||
///
|
||||
/// ⚠️ **Emitted, not yet drawn.** Parent and leaf each carry their own alpha
|
||||
/// ramp on a different span — parent 0→255 over t=70…238, leaf
|
||||
/// 255→0x80→255 over t=150…600 — so how the two compose is a *decoding*
|
||||
/// question and not the port's to answer. The data is exported so it stops
|
||||
/// being invisible; `ScreenView` ignores it until the composition rule is
|
||||
/// known.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub leaf: Option<Focus>,
|
||||
|
||||
/// True when the leaf's geometry DIFFERS from the parent's, so the leaf is
|
||||
/// what the game draws.
|
||||
///
|
||||
/// Decided here rather than in the runtime because it is disc knowledge.
|
||||
/// The Decoder's rule: *"the discriminator is which record carries the
|
||||
/// geometry, not a fixed order"* — and the census over this export splits
|
||||
/// cleanly, with no ambiguous middle:
|
||||
///
|
||||
/// * **30 of 46** leaf elements duplicate the parent's scale and rotation
|
||||
/// exactly. That is the BASE-record case `screen.rs` already handled: the
|
||||
/// leaf may differ by a unit of position (`ptbtn04`: parent y=401, leaf
|
||||
/// y=402) and the parent wins. Flag is false; nothing changes.
|
||||
/// * **16 differ**, and all of them differ in scale or rotation, not by a
|
||||
/// rounding unit: the ten `ptloop01`/`ptloop02` sweeps ((100,600) at +30°
|
||||
/// and (100,800) at −45° against an identity parent), two
|
||||
/// `pgloading_ring` (leaf scale **(0,0)**), and `title_jp`'s
|
||||
/// `ptlogo_eff2` (**parent 125 %, leaf 100 %**).
|
||||
///
|
||||
/// ⚠️ **Only the `ptloop` case is decoded.** The Decoder fitted the game's
|
||||
/// own composed alpha — vertex colours `C3FFFFFF`/`B6FFFFFF`, i.e. 195 and
|
||||
/// 182 — against the two leaf ramps and got one consistent time, t=355, then
|
||||
/// *predicted* the quad centres at 981 and 478 against 992.0 and 467.2
|
||||
/// measured. The other two are the same shape and are **not** separately
|
||||
/// confirmed; they are flagged so the harness can adjudicate them rather
|
||||
/// than being asserted.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub leaf_carries_geometry: bool,
|
||||
/// The raw `opt ` link inside this element's `.rat` record.
|
||||
///
|
||||
/// ⚠️ **This is not a focus link.** It was read as one, and that was
|
||||
@@ -161,6 +264,23 @@ pub struct Element {
|
||||
/// Paint-order key. `"sprite"` = read from the `T8aD` header at `+0x0A`.
|
||||
/// `"implied"` = **measured off the running game**, for elements that carry
|
||||
/// no header. `"none"` = neither; sorts last.
|
||||
/// `true` when the game draws this element ADDITIVE — `T8aD +0x04` bit
|
||||
/// `0x02`.
|
||||
///
|
||||
/// 🔴 **DECODED, and it replaces an authored map.** The port carried an
|
||||
/// `additive_elements` table in `authored/rendering.json`, keyed by SCREEN
|
||||
/// NAME and transcribed from the Decoder's per-draw `RB_BLENDCONTROL0` log.
|
||||
/// A name-keyed map cannot answer for a screen nobody drove the game to,
|
||||
/// which is why the port was drawing the English menus additive and the
|
||||
/// Japanese ones alpha-over — asserting by omission that the JP build
|
||||
/// blends differently. The bit is on the disc for every screen at once.
|
||||
///
|
||||
/// ⚠️ `kind_raw` is NOT this field. `kind` is `+40` of the RATC declaration
|
||||
/// entry; this is `+0x04` of the sprite's own `T8aD` header. Tested over
|
||||
/// four screens: `kind & 0x2` is *anti*-correlated with the measured map —
|
||||
/// 0 of 14 additive elements set it and 9 non-additive ones do.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blend_additive: Option<bool>,
|
||||
pub layer_source: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub layer: Option<String>,
|
||||
@@ -201,6 +321,35 @@ pub struct Screen {
|
||||
/// **Geometric, not a decoded neighbour graph** — right for a vertical menu
|
||||
/// and not to be trusted for anything else.
|
||||
pub buttons: Vec<String>,
|
||||
|
||||
/// The instant every element of this screen is settled at, and the width of
|
||||
/// the interval it was taken from — `[start, end, midpoint]` in keyframe
|
||||
/// units, absent when the screen has fewer than two keyframe times.
|
||||
///
|
||||
/// 🔴 **A SETTLED SCREEN IS ONE INSTANT, AND THE DISC SAYS WHICH.** Posing
|
||||
/// each element at its own `rest()` is right for anything that ends the
|
||||
/// screen settled and **exactly wrong for a transient**: the title's
|
||||
/// `ptlogo_back2eff1` is a two-frame flash — 0 until t52, 255 at t54–56, 0
|
||||
/// again by t58 — so its last *hold* is the flash peak and `rest()` leaves
|
||||
/// it burning forever. There are five of these, and `rest()` draws all five
|
||||
/// at once, saturating the light arc.
|
||||
///
|
||||
/// The window is the **longest interval containing no keyframe time**, over
|
||||
/// this bundle's TOP-LEVEL elements only. Nested leaves are excluded, and
|
||||
/// that exclusion is what reproduces the Decoder's independently computed
|
||||
/// `[160, 236]` for the title: including the `ptloop` leaves gives
|
||||
/// `[269, 540]` instead.
|
||||
///
|
||||
/// ⚠️ **Emitted for every screen; USABLE only where it is wide.** Across this
|
||||
/// export the widths split with nothing in between — `press_start` 214,
|
||||
/// `publisher_logo` 190, `developer_logos` 145, `title` 76, then
|
||||
/// `main_menu` 12, `extras` 12, the loading screens 8 and 4. A 12-unit
|
||||
/// "settle" on a menu that builds in until t=70 is not a settled pose, it is
|
||||
/// a gap between staggered ramps. The Decoder's disc-wide census agrees on
|
||||
/// the shape: only 30 % of bundles have a window ≥ 30 units and 42 % have
|
||||
/// one under 10, the latter mostly `loop*` fragments meant to be in motion.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub settle_window: Option<[i64; 3]>,
|
||||
/// What this file does not answer. A consumer needing one of these must get
|
||||
/// it from `authored/`.
|
||||
pub unresolved: Vec<&'static str>,
|
||||
@@ -316,6 +465,76 @@ pub fn export_build(
|
||||
// Contrast with a BASE record, where the leaf duplicates the parent's
|
||||
// placement and the two can differ by a unit (ptbtn04: parent y=401,
|
||||
// leaf y=402). There the parent wins. Here there is no parent.
|
||||
// Reads one record in the bundle as a nested build and returns its
|
||||
// elements. Used twice: for a FOCUS record (`ptbtn0Nf.rat`) and for an
|
||||
// element whose OWN declared name is a record (`ptloop01.rat`). One
|
||||
// implementation, because the second case was missing for eight
|
||||
// milestones and a second copy is how it would go missing again.
|
||||
let read_leaf = |rec: &str,
|
||||
written: &mut std::collections::BTreeMap<String, ()>,
|
||||
missing: &mut Vec<String>|
|
||||
-> Result<Option<Focus>> {
|
||||
let Some(&(off, size)) = b.records.get(rec) else { return Ok(None) };
|
||||
let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut fes = Vec::new();
|
||||
for fe in &leaf.elements {
|
||||
let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name);
|
||||
let mut fsprite = None;
|
||||
if write_from(&sprite_dir, written, sp, &bundle[off..off + size], &leaf.sprites)?
|
||||
|| write_from(&sprite_dir, written, sp, bundle, &b.sprites)?
|
||||
{
|
||||
fsprite = Some(sprite_rel(sp));
|
||||
} else if sp.ends_with(".t32") {
|
||||
missing.push(sp.to_string());
|
||||
}
|
||||
let Some(r) = fe.rest() else { continue };
|
||||
fes.push(FocusElement {
|
||||
id: id_of(&fe.name),
|
||||
declared: fe.name.clone(),
|
||||
sprite: fsprite,
|
||||
blend_additive: ui_layout::blend_additive_by_name(
|
||||
&leaf, &bundle[off..off + size], sp)
|
||||
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
|
||||
pivot: [fe.pivot_x, fe.pivot_y],
|
||||
rest: Rest {
|
||||
pos: [r.x, r.y],
|
||||
scale: [r.scale_x, r.scale_y],
|
||||
tint_rgba: hex32(r.tint),
|
||||
fade_argb: hex32(r.fade),
|
||||
rotation_deg: r.rotation_deg,
|
||||
t: r.time,
|
||||
},
|
||||
keyframes: fe
|
||||
.keyframes
|
||||
.iter()
|
||||
.map(|k| Keyframe {
|
||||
t: k.time,
|
||||
pos: [k.x, k.y],
|
||||
scale: [k.scale_x, k.scale_y],
|
||||
tint_rgba: hex32(k.tint),
|
||||
fade_argb: hex32(k.fade),
|
||||
rotation_deg: k.rotation_deg,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
Ok(if fes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Focus {
|
||||
record: rec.to_string(),
|
||||
loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]),
|
||||
elements: fes,
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
// An element whose own declared name is a record in this bundle carries
|
||||
// its geometry THERE, not in its parent entry. See `Element::leaf`.
|
||||
let leaf = read_leaf(&el.name, &mut written, &mut missing)?;
|
||||
|
||||
let mut focus = None;
|
||||
if let Some(rec) = highlight_name(&el.name) {
|
||||
if let Some(&(off, size)) = b.records.get(&rec) {
|
||||
@@ -341,6 +560,9 @@ pub fn export_build(
|
||||
id: id_of(&fe.name),
|
||||
declared: fe.name.clone(),
|
||||
sprite: fsprite,
|
||||
blend_additive: ui_layout::blend_additive_by_name(
|
||||
&leaf, &bundle[off..off + size], sp)
|
||||
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
|
||||
pivot: [fe.pivot_x, fe.pivot_y],
|
||||
rest: Rest {
|
||||
pos: [r.x, r.y],
|
||||
@@ -365,7 +587,11 @@ pub fn export_build(
|
||||
});
|
||||
}
|
||||
if !fes.is_empty() {
|
||||
focus = Some(Focus { record: rec, elements: fes });
|
||||
focus = Some(Focus {
|
||||
record: rec,
|
||||
loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]),
|
||||
elements: fes,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,10 +623,21 @@ pub fn export_build(
|
||||
sprite: sprite_out,
|
||||
focus_sprite,
|
||||
focus,
|
||||
leaf_carries_geometry: leaf.as_ref().is_some_and(|l| {
|
||||
let p = el.rest();
|
||||
l.elements.iter().any(|le| {
|
||||
p.is_none_or(|p| {
|
||||
le.rest.scale != [p.scale_x, p.scale_y]
|
||||
|| le.rest.rotation_deg != p.rotation_deg
|
||||
})
|
||||
})
|
||||
}),
|
||||
leaf,
|
||||
opt_link: el.focus_link.clone(),
|
||||
pivot: [el.pivot_x, el.pivot_y],
|
||||
size: (role == "primitive").then(|| [el.pivot_x * 2, el.pivot_y * 2]),
|
||||
parent: el.parent,
|
||||
blend_additive: ui_layout::sprite_blend_additive(&b, bundle, el),
|
||||
layer_source,
|
||||
layer,
|
||||
focused: el.focused,
|
||||
@@ -419,14 +656,33 @@ pub fn export_build(
|
||||
|
||||
// Navigation order is geometric: buttons top-to-bottom by resting Y. A
|
||||
// focused-state record is not itself a menu item.
|
||||
//
|
||||
// 🔴 `0x3003` IS `0x3002`. Bit 0 of `kind` is the PARENT FLAG and carries no
|
||||
// role information: decoded disc-wide over every `.pak` in `dat/`, `kind & 1`
|
||||
// agrees with "has a parent" on 15 493 elements with ZERO disagreements
|
||||
// (`docs/re/ui-kind-bit0-is-has-parent.md`). Matching only `0x3002` meant the
|
||||
// OPTIONS menu's five rows -- parented, hence `0x3003` -- were not buttons,
|
||||
// so the screen opened and could not be navigated.
|
||||
//
|
||||
// ⚠️ TWO VALUES, LISTED, NOT A MASK. `kind & 0xFFFE == 0x3002` would also
|
||||
// match `0x73002`/`0x73003` -- 160 elements whose `0x70000` bits nobody has
|
||||
// decoded -- and it would do it silently, on screens neither agent has
|
||||
// looked at. Those are excluded by construction until somebody decides about
|
||||
// them deliberately.
|
||||
let mut buttons: Vec<(i32, String)> = b
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|e| e.kind == 0x3002 && !e.focused)
|
||||
.filter(|e| matches!(e.kind, 0x3002 | 0x3003) && !e.focused)
|
||||
.filter_map(|e| e.rest().map(|k| (k.y, id_of(&e.name))))
|
||||
.collect();
|
||||
buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
||||
|
||||
let window = settle_window(&elements);
|
||||
let order = forced_backdrop_first(
|
||||
ui_layout::derived_paint_order(&b, bundle),
|
||||
&elements,
|
||||
[b.design_w, b.design_h],
|
||||
);
|
||||
let screen = Screen {
|
||||
format: "sylpheed.screen/3",
|
||||
exporter: exporter.to_string(),
|
||||
@@ -441,8 +697,9 @@ pub fn export_build(
|
||||
name_why,
|
||||
design: [b.design_w, b.design_h],
|
||||
elements,
|
||||
paint_order: ui_layout::derived_paint_order(&b, bundle),
|
||||
paint_order: order,
|
||||
buttons: buttons.into_iter().map(|(_, n)| n).collect(),
|
||||
settle_window: window,
|
||||
unresolved: vec![
|
||||
// The time unit is measured off the running game, not on the disc.
|
||||
"keyframe_time_unit",
|
||||
@@ -473,3 +730,234 @@ pub fn export_build(
|
||||
missing,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/// The longest interval containing no keyframe time, over TOP-LEVEL elements.
|
||||
///
|
||||
/// See [`Screen::settle_window`] for why this is the settled instant and why
|
||||
/// nested leaves are excluded. Returns `[start, end, midpoint]`.
|
||||
fn settle_window(elements: &[Element]) -> Option<[i64; 3]> {
|
||||
let mut times: Vec<i64> = elements
|
||||
.iter()
|
||||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t.map(i64::from)))
|
||||
.collect();
|
||||
times.sort_unstable();
|
||||
times.dedup();
|
||||
if times.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// 🔴 A GAP IN WHICH NOTHING IS VISIBLE IS NOT A SETTLE WINDOW.
|
||||
//
|
||||
// The widest keyframe-free interval is only a settled state if the screen is
|
||||
// actually PRESENTING something across it. `press_start` is the case that
|
||||
// proves it: its keyframes are 0, 214, 236, 238, 244, so the widest gap is
|
||||
// 0..214 -- the dead stretch BEFORE the plate appears, where `ptbtn00` is
|
||||
// alpha 0 throughout. Taking its midpoint gave a settle instant of t=107,
|
||||
// and the runtime then answered every question about that screen at t=107.
|
||||
// The result was that the PRESS (A) plate could not be drawn at any instant
|
||||
// at all, including the boot's own end state, whose entire purpose is to
|
||||
// show it.
|
||||
//
|
||||
// The fix is not a tuned threshold: it is that the heuristic was reading an
|
||||
// interval where the screen is BLANK as the interval where it has arrived.
|
||||
// Rejecting those leaves `press_start` with 214..236 (22 units), which is
|
||||
// under the runtime's 30-unit bar, so it falls back to each element's own
|
||||
// hold -- which is the plate, opaque, exactly as the disc declares it.
|
||||
//
|
||||
// ⚠️ This does not disturb the windows the settle instant was measured on.
|
||||
// `title` keeps [160, 236]: elements are visible across it, and the
|
||||
// Decoder's draw stream independently found the game's clock freezing in
|
||||
// that same interval.
|
||||
let visible_at = |t: i64| elements.iter().any(|e| alpha_at(e, t) > 0);
|
||||
let (a, b) = times
|
||||
.windows(2)
|
||||
.map(|w| (w[0], w[1]))
|
||||
.filter(|(a, b)| visible_at((a + b) / 2))
|
||||
.max_by_key(|(a, b)| b - a)?;
|
||||
Some([a, b, (a + b) / 2])
|
||||
}
|
||||
|
||||
|
||||
/// Alpha of one element at instant `t`, under the linear ramp the port uses.
|
||||
fn alpha_at(e: &Element, t: i64) -> u8 {
|
||||
let ks = &e.keyframes;
|
||||
let a = |k: &Keyframe| (u32::from_str_radix(k.fade_argb.trim_start_matches("0x"), 16)
|
||||
.unwrap_or(0) >> 24) as i64;
|
||||
let timed: Vec<&Keyframe> = ks.iter().filter(|k| k.t.is_some()).collect();
|
||||
if timed.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
if t <= timed[0].t.unwrap() as i64 {
|
||||
return a(timed[0]) as u8;
|
||||
}
|
||||
for w in timed.windows(2) {
|
||||
let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64);
|
||||
if t < t1 {
|
||||
if t1 <= t0 {
|
||||
return a(w[0]) as u8;
|
||||
}
|
||||
let f = (t - t0) as f64 / (t1 - t0) as f64;
|
||||
return (a(w[0]) as f64 + (a(w[1]) - a(w[0])) as f64 * f).round() as u8;
|
||||
}
|
||||
}
|
||||
a(timed[timed.len() - 1]) as u8
|
||||
}
|
||||
|
||||
/// Scale of one element at instant `t`, in percent per axis, under the same
|
||||
/// linear ramp as the fade. Interpolated rather than stepped, because a scale
|
||||
/// that animates passes through every value between its keyframes.
|
||||
fn scale_at(e: &Element, t: i64) -> [f64; 2] {
|
||||
let timed: Vec<&Keyframe> = e.keyframes.iter().filter(|k| k.t.is_some()).collect();
|
||||
if timed.is_empty() {
|
||||
return [100.0, 100.0];
|
||||
}
|
||||
let g = |k: &Keyframe, i: usize| k.scale[i] as f64;
|
||||
if t <= timed[0].t.unwrap() as i64 {
|
||||
return [g(timed[0], 0), g(timed[0], 1)];
|
||||
}
|
||||
for w in timed.windows(2) {
|
||||
let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64);
|
||||
if t < t1 {
|
||||
if t1 <= t0 {
|
||||
return [g(w[0], 0), g(w[0], 1)];
|
||||
}
|
||||
let f = (t - t0) as f64 / (t1 - t0) as f64;
|
||||
return [
|
||||
g(w[0], 0) + (g(w[1], 0) - g(w[0], 0)) * f,
|
||||
g(w[0], 1) + (g(w[1], 1) - g(w[0], 1)) * f,
|
||||
];
|
||||
}
|
||||
}
|
||||
let l = timed[timed.len() - 1];
|
||||
[g(l, 0), g(l, 1)]
|
||||
}
|
||||
|
||||
/// Move a full-screen opaque primitive to the FRONT of the paint order when the
|
||||
/// file forces it there.
|
||||
///
|
||||
/// 🔴 **The rule is a constraint, not a preference**, and it is the Decoder's:
|
||||
/// *an element that covers the screen and is fully opaque at some instant cannot
|
||||
/// paint above anything visible at that instant; where the elements visible
|
||||
/// during its opaque span are ALL of them, its position is forced to first.*
|
||||
///
|
||||
/// It was found because `build_12`/`build_15` are **black at every instant** of
|
||||
/// their declared timeline under the old rule — `pgloading_eff00` is opaque for
|
||||
/// 39 instants while all 9 other elements live and die inside that span. A
|
||||
/// screen that is black for its whole life is impossible on its face, which is
|
||||
/// the only kind of check that survives two renderers sharing an assumption:
|
||||
/// `sylpheed-cli` agreed with the port here because it agreed about
|
||||
/// `implied_layer_key`.
|
||||
///
|
||||
/// Two measured controls, both prior orders off the running game:
|
||||
///
|
||||
/// | primitive | measured | opaque instants | forced below | |
|
||||
/// |---|---|---|---|---|
|
||||
/// | `palogo_eff0.prm` | **first** | 211 | 6 of 6 | ✅ forced |
|
||||
/// | `pteff00.prm` | **last** | 2 | 3 of 23 | ✅ permitted on top |
|
||||
///
|
||||
/// ⚠️ **Do NOT reduce this to a name heuristic.** `*base*` first / `*eff*` last
|
||||
/// matches 77 of 80 and fails on exactly the three families that cross it —
|
||||
/// `palogo_eff0`, `pgloading_eff00`, `pzeff00`. `palogo_eff0.prm` is *named like
|
||||
/// an overlay* and is measured painting first. The name is not the rule.
|
||||
///
|
||||
/// 🔴 **And it is restricted to elements with NO SPRITE**, which is the limit
|
||||
/// that the rule's own disc-wide test caught: applied to sprites it claimed 22
|
||||
/// `.t32` textures must sort first *against their own layer keys*. **An
|
||||
/// element's alpha says nothing about whether its texture covers the screen** —
|
||||
/// most of a sprite may be transparent.
|
||||
///
|
||||
/// ⚠️ Reach: assumes straight alpha-over. Blend mode is undecoded, and an
|
||||
/// additive quad at alpha 255 would not occlude. It is a lower bound, not an
|
||||
/// ordering — it says nothing about elements that are constrained but not
|
||||
/// forced. Delete this when a pinned `sylpheed-formats` does it.
|
||||
fn forced_backdrop_first(order: Vec<usize>, elements: &[Element], design: [u32; 2]) -> Vec<usize> {
|
||||
let screen_end: i64 = elements
|
||||
.iter()
|
||||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t))
|
||||
.map(i64::from)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let forced: Vec<usize> = elements
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| {
|
||||
// 🔴 UNTEXTURED SOLID QUAD, tested positively -- NOT merely "has no
|
||||
// sprite". Those coincide in GP_TITLE and the distinction is still
|
||||
// the whole point, because the negative test guards a SYMPTOM.
|
||||
//
|
||||
// The rule needs the element's alpha to BE its pixels' alpha. That
|
||||
// is true of a `.prm` solid quad and of nothing else. The Decoder
|
||||
// found this the expensive way twice: first `.t32` sprites (an
|
||||
// element's alpha says nothing about a texture that is mostly
|
||||
// transparent), guarded with "no sprite" -- and then `.tbm`, which
|
||||
// is 38 of their 80 forced-first verdicts and declares fade
|
||||
// `ffffffff`. A solid WHITE quad painted first at alpha 255 would
|
||||
// make the screen white; no screen is white, so a `.tbm`'s white is
|
||||
// a modulation ON a texture and its element alpha proves nothing
|
||||
// about coverage either.
|
||||
//
|
||||
// "No sprite" would keep admitting a `.tbm` that this exporter
|
||||
// happens not to emit a sprite for. `role == "primitive"` cannot.
|
||||
// GP_TITLE has no full-screen `.tbm` at all -- every layerless
|
||||
// full-screen element here is `.prm` and pure black, checked -- so
|
||||
// this changes no verdict today and is a guard against a corpus
|
||||
// that grows.
|
||||
// Cheap prefilter only -- the binding coverage test is per-instant,
|
||||
// in `covers` below. An element scaled ABOVE 100 could cover the
|
||||
// screen from a smaller declared size, so this deliberately does
|
||||
// not reject on size.
|
||||
e.role == "primitive" && e.sprite.is_none() && e.size.is_some()
|
||||
})
|
||||
.filter(|(i, e)| {
|
||||
let span: Vec<i64> = e
|
||||
.keyframes
|
||||
.iter()
|
||||
.filter_map(|k| k.t)
|
||||
.map(i64::from)
|
||||
.collect();
|
||||
let Some(&lo) = span.first() else { return false };
|
||||
// 🔴 COVERAGE IS TESTED AT EACH INSTANT, NOT ONCE FROM `size`.
|
||||
// Declared size alone is not what the element draws: scale is a
|
||||
// percent per axis and it animates. `pbafc.prm` is the disc's own
|
||||
// counterexample -- declared 844x600, scaled 2 % x 3 %, so it draws
|
||||
// about 17x18 px, a moving glint rather than a wash. A rule that
|
||||
// read its declared size would call it screen-covering.
|
||||
//
|
||||
// Nothing in GP_TITLE needs this: every layerless full-screen
|
||||
// element here is at scale 100 on every keyframe, so no verdict
|
||||
// moves. It is in because the data that would break it exists on
|
||||
// this disc, which is a better reason than a failure would have been.
|
||||
let covers = |t: i64| {
|
||||
let sc = scale_at(e, t);
|
||||
e.size.is_some_and(|s| {
|
||||
s[0] as f64 * sc[0] / 100.0 >= design[0] as f64
|
||||
&& s[1] as f64 * sc[1] / 100.0 >= design[1] as f64
|
||||
})
|
||||
};
|
||||
// An element HOLDS ITS FINAL POSE to the end of the screen -- it does
|
||||
// not vanish at its own last keyframe. `palogo_eff0.prm` is the case
|
||||
// that shows why: it declares ONE keyframe, opaque black full-screen
|
||||
// at t=0, and reading its span as `0..=0` makes the splash's backdrop
|
||||
// a single-instant event instead of the thing that is on screen for
|
||||
// the whole splash. So the span runs to the SCREEN's last keyframe.
|
||||
let hi = screen_end.max(*span.last().unwrap());
|
||||
let opaque: Vec<i64> = (lo..=hi)
|
||||
.filter(|&t| alpha_at(e, t) == 255 && covers(t))
|
||||
.collect();
|
||||
if opaque.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Every OTHER element must be visible somewhere inside that span.
|
||||
elements.iter().enumerate().all(|(j, o)| {
|
||||
j == *i || opaque.iter().any(|&t| alpha_at(o, t) > 0)
|
||||
})
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if forced.is_empty() {
|
||||
return order;
|
||||
}
|
||||
let mut out = forced.clone();
|
||||
out.extend(order.into_iter().filter(|i| !forced.contains(i)));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap(); let i:usize=a.next().unwrap().parse().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
let by=ar.read(&ar.entries()[i]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
println!("entry {i}: {} elements, {} records, {} sprites",
|
||||
b.elements.len(), b.records.len(), b.sprites.len());
|
||||
let mut rk:Vec<&String>=b.records.keys().collect(); rk.sort();
|
||||
println!(" records: {:?}", rk);
|
||||
for (rn,&(o,sz)) in &b.records {
|
||||
if o+sz>by.len() { continue }
|
||||
let Some(lb)=ui_layout::parse_build(&by[o..o+sz]) else { continue };
|
||||
println!(" RECORD {rn}: {} elements", lb.elements.len());
|
||||
for le in &lb.elements {
|
||||
let lts:Vec<String>=le.keyframes.iter()
|
||||
.map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect();
|
||||
println!(" [{}] {:<22} kind=0x{:<6x} {}", le.index, le.name, le.kind, lts.join(" "));
|
||||
}
|
||||
}
|
||||
for e in &b.elements {
|
||||
let ts:Vec<String>=e.keyframes.iter()
|
||||
.map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect();
|
||||
println!(" [{}] {:<24} kind=0x{:<6x} {}", e.index, e.name, e.kind, ts.join(" "));
|
||||
if let Some(&(o,s))=b.records.get(&e.name) {
|
||||
if o+s<=by.len() {
|
||||
if let Some(lb)=ui_layout::parse_build(&by[o..o+s]) {
|
||||
for le in &lb.elements {
|
||||
let lts:Vec<String>=le.keyframes.iter()
|
||||
.map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect();
|
||||
println!(" leaf {:<20} kind=0x{:<6x} {}", le.name, le.kind, lts.join(" "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
for t in a {
|
||||
let i:usize=t.parse().unwrap();
|
||||
let by=ar.read(&ar.entries()[i]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
println!("=== entry {i} ===");
|
||||
let order=ui_layout::derived_paint_order(&b,&by);
|
||||
for e in &b.elements {
|
||||
let k=ui_layout::sprite_layer_key(&b,&by,e);
|
||||
let pos=order.iter().position(|&x|x==e.index);
|
||||
println!(" [{}] {:<24} kind=0x{:<5x} sprite={:<24} key={:<12} paint#{:?}",
|
||||
e.index, e.name, e.kind,
|
||||
e.sprite.clone().unwrap_or_else(||"<none>".into()),
|
||||
k.map(|v|format!("0x{v:08x}")).unwrap_or_else(||"NONE".into()), pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
fn main(){
|
||||
let root=std::env::var("SYLPHEED_DISC").unwrap_or_else(|_|"/disc".into());
|
||||
let ar=pak::PakArchive::open(format!("{root}/dat/GP_READY_ROOM.pak")).unwrap();
|
||||
for (i,e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by)=ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b)=ui_layout::parse_build(&by) else { continue };
|
||||
let Some(p)=b.elements.iter().find(|el|el.name=="pbafc.prm") else { continue };
|
||||
println!("=== entry {i}: {} elements ===", b.elements.len());
|
||||
println!(" pbafc.prm pivot=({},{}) -> {}x{}", p.pivot_x,p.pivot_y,p.pivot_x*2,p.pivot_y*2);
|
||||
for k in &p.keyframes {
|
||||
println!(" t={:<5} fade={:08x} a={:<4} xy=({},{}) s={}/{}",
|
||||
k.time.map(|v|v as i64).unwrap_or(-1), k.fade, k.fade>>24, k.x,k.y,k.scale_x,k.scale_y);
|
||||
}
|
||||
// what does it cover, and is anything visible while it is opaque?
|
||||
let rest=p.rest().unwrap();
|
||||
let (px,py,pw,ph)=(rest.x, rest.y, (p.pivot_x*2) as i32, (p.pivot_y*2) as i32);
|
||||
println!(" its rect at rest: ({px},{py}) {pw}x{ph}");
|
||||
let tmax=b.elements.iter().flat_map(|e|e.keyframes.iter().filter_map(|k|k.time)).max().unwrap_or(0);
|
||||
let op:Vec<u32>=(0..=tmax).filter(|&t|p.pose_at(t).map(|k|k.fade>>24)==Some(255)).collect();
|
||||
println!(" opaque at {} instants (t={:?}..{:?}) of 0..{tmax}", op.len(), op.first(), op.last());
|
||||
let mut cov=0; let mut vis=0;
|
||||
for o in &b.elements {
|
||||
if o.index==p.index { continue }
|
||||
let Some(ok)=o.rest() else { continue };
|
||||
let (ow,oh)=((o.pivot_x*2) as i32,(o.pivot_y*2) as i32);
|
||||
let overlap=(px+pw).min(ok.x+ow)-px.max(ok.x)>0 && (py+ph).min(ok.y+oh)-py.max(ok.y)>0;
|
||||
if !overlap { continue }
|
||||
cov+=1;
|
||||
if op.iter().any(|&t| o.pose_at(t).map(|k|k.fade>>24).unwrap_or(0)>0) {
|
||||
vis+=1;
|
||||
if vis<=6 { println!(" covered AND visible while opaque: {}", o.name); }
|
||||
}
|
||||
}
|
||||
println!(" elements its rect covers: {cov}; visible while it is opaque: {vis}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
for t in a {
|
||||
let i:usize=t.parse().unwrap();
|
||||
let by=ar.read(&ar.entries()[i]).unwrap();
|
||||
let Some(b)=ui_layout::parse_build(&by) else { continue };
|
||||
let top=u32::from_be_bytes(by[8..12].try_into().unwrap());
|
||||
println!("entry {i:2} TOP +08 = {top}");
|
||||
let mut ks:Vec<&String>=b.records.keys().collect(); ks.sort();
|
||||
for rn in ks {
|
||||
let &(o,s)=b.records.get(rn).unwrap();
|
||||
if o+16>by.len() { continue }
|
||||
let magic=&by[o..o+4];
|
||||
let h4=u32::from_be_bytes(by[o+4..o+8].try_into().unwrap());
|
||||
let h8=u32::from_be_bytes(by[o+8..o+12].try_into().unwrap());
|
||||
let maxt=ui_layout::parse_build(&by[o..o+s])
|
||||
.map(|lb|lb.elements.iter().flat_map(|e|e.keyframes.iter()
|
||||
.filter_map(|k|k.time)).max().unwrap_or(0)).unwrap_or(0);
|
||||
println!(" {rn:<24} magic={:?} +04={:08x}({:.1}) +08={h8:<6} max keyframe t={maxt} ratio={:.4}",
|
||||
String::from_utf8_lossy(magic), h4, h4 as f64/65536.0,
|
||||
if maxt>0 {h8 as f64/maxt as f64} else {0.0});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
//! Does `resolve_movie_voice_region` start LATE, and by exactly how much?
|
||||
//!
|
||||
//! The port agent's arithmetic: the running decoder's three `ADV` XMA contexts sum
|
||||
//! to **3 584 000** payload bytes, but the resolved voice region is **3 114 352** —
|
||||
//! 15 % too small to hold them. One of the two spans is not what the other thinks
|
||||
//! it is, and the disc side is this crate's.
|
||||
//!
|
||||
//! The gap is exact. `ctx0` declares **632** packets (1 294 336 B); the leading
|
||||
//! chunk the resolver yields has **394** (806 912 B). The difference is **238
|
||||
//! packets = 487 424 B**, a whole number of packets — which is what a start offset
|
||||
//! looks like, not corruption.
|
||||
//!
|
||||
//! So: walk the region start backwards and report where `to_xma_riffs` first
|
||||
//! reproduces the decoder's own three sizes. The probe's byte_sizes are the
|
||||
//! control — this is not free to fit, it either lands on them or it does not.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example adv_region_extend
|
||||
|
||||
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
|
||||
/// What the running decoder reported (docs/re/structures/voice-three-streams-are-concurrent.md).
|
||||
const WANT: [usize; 3] = [1_294_336, 1_118_208, 1_171_456];
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
let (start, end) =
|
||||
media::resolve_movie_voice_region(&src, "ADV", VoiceLang::English).expect("region");
|
||||
println!("resolver says {start}..{end} ({} B)", end - start);
|
||||
println!("decoder wants {:?} = {} B payload\n", WANT, WANT.iter().sum::<usize>());
|
||||
|
||||
for back_packets in [0usize, 100, 200, 237, 238, 239, 300, 400] {
|
||||
let back = (back_packets * 2048) as u64;
|
||||
if back > start {
|
||||
continue;
|
||||
}
|
||||
let s = start - back;
|
||||
let Ok(bytes) = src.read_segment_range("dat/sound", s, (end - s) as usize) else {
|
||||
println!("-{back_packets:4} packets: unreadable");
|
||||
continue;
|
||||
};
|
||||
let riffs = slb::to_xma_riffs(&bytes);
|
||||
let sizes: Vec<usize> = riffs.iter().map(|r| r.len() - 60).collect();
|
||||
let hit = sizes.len() == 3 && sizes.iter().zip(WANT.iter()).all(|(a, b)| a == b);
|
||||
println!(
|
||||
"-{back_packets:4} packets (start {s}): {} chunk(s) {:?}{}",
|
||||
riffs.len(),
|
||||
sizes,
|
||||
if hit { " <== MATCHES THE DECODER" } else { "" }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//! Dump `ADV`'s voice chunks as RIFF/XMA, so each can be decoded and identified.
|
||||
//!
|
||||
//! `intro-audio-decomposed.md` measured that the intro's output is the movie's own
|
||||
//! WMA Pro 5.1 track at 0.600 **plus** three streams occupying a front pair, a
|
||||
//! centre (with a silent partner) and a rear pair. What it could **not** say is
|
||||
//! *which* stream sits where — the assignment there is by position, not content.
|
||||
//! The port needs that to weight a positional downmix.
|
||||
//!
|
||||
//! This writes the chunks out so they can be decoded (ffmpeg has `xma2`) and
|
||||
//! correlated against the per-channel residuals.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example adv_voice_dump -- OUTDIR [MOVIE]
|
||||
|
||||
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
|
||||
fn main() {
|
||||
let out = std::env::args().nth(1).expect("OUTDIR");
|
||||
let movie = std::env::args().nth(2).unwrap_or_else(|| "ADV".into());
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
std::fs::create_dir_all(&out).expect("outdir");
|
||||
|
||||
let (start, end) = media::resolve_movie_voice_region(&src, &movie, VoiceLang::English)
|
||||
.expect("voice region");
|
||||
let bytes = src
|
||||
.read_segment_range("dat/sound", start, (end - start) as usize)
|
||||
.expect("region");
|
||||
println!("{movie}: region {start}..{end} = {} B", end - start);
|
||||
|
||||
let riffs = slb::to_xma_riffs(&bytes);
|
||||
println!("{} RIFF chunk(s)", riffs.len());
|
||||
for (i, r) in riffs.iter().enumerate() {
|
||||
let p = format!("{out}/{movie}_{i}.xma");
|
||||
std::fs::write(&p, r).expect("write");
|
||||
// the probe reports `byte_size` = RIFF total - 60; print both so the
|
||||
// dump can be tied to a specific XMA context by its own number
|
||||
println!(" chunk {i}: {} B byte_size-equivalent {} -> {p}",
|
||||
r.len(), r.len() as i64 - 60);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//! List a sound bank's streams and their declared rates.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example bank_streams -- <disc> BGM_102.slb …
|
||||
use sylpheed_formats::media::{self, DirectorySource};
|
||||
use sylpheed_formats::{hash::name_hash, slb};
|
||||
|
||||
fn main() {
|
||||
let mut a = std::env::args().skip(1);
|
||||
let disc = a.next().expect("usage: bank_streams <disc> NAME.slb…");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
for name in a {
|
||||
let h = name_hash(&name);
|
||||
match media::read_sound_bank(&src, h) {
|
||||
Ok(bytes) => {
|
||||
let riffs = slb::to_xma_riffs(&bytes);
|
||||
println!(
|
||||
"{name} (hash {h:08x}, {} B on disc) header {:?} -> {} stream(s)",
|
||||
bytes.len(),
|
||||
slb::bank_header_len(&bytes),
|
||||
riffs.len()
|
||||
);
|
||||
for (i, r) in riffs.iter().enumerate() {
|
||||
let rate = if r.len() >= 0x28 {
|
||||
u32::from_le_bytes(r[0x20..0x24].try_into().unwrap())
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let payload = r.len() - 60;
|
||||
println!(
|
||||
" stream {i}: payload {payload} B ({} packets) declared {rate} B/s \
|
||||
=> {:.3} s",
|
||||
payload / 2048,
|
||||
if rate > 0 { payload as f64 / rate as f64 } else { 0.0 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => println!("{name}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
//! Dump one BGM bank's waves as RIFF/XMA so they can be decoded and compared
|
||||
//! against a capture of the running game.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example bgm_dump -- BGM_103.slb OUTDIR
|
||||
use sylpheed_formats::media::{self, DirectorySource};
|
||||
use sylpheed_formats::slb;
|
||||
|
||||
fn main() {
|
||||
let name = std::env::args().nth(1).unwrap_or_else(|| "BGM_103.slb".into());
|
||||
let out = std::env::args().nth(2).expect("OUTDIR");
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
std::fs::create_dir_all(&out).expect("outdir");
|
||||
let h = sylpheed_formats::hash::name_hash(&name);
|
||||
let bytes = media::read_sound_bank(&src, h).expect("bank");
|
||||
println!("{name}: {} B", bytes.len());
|
||||
for (i, r) in slb::to_xma_riffs(&bytes).iter().enumerate() {
|
||||
let p = format!("{out}/{}_{i}.xma", name.trim_end_matches(".slb"));
|
||||
std::fs::write(&p, r).expect("write");
|
||||
println!(" wave {i}: {} B (byte_size {}) -> {p}", r.len(), r.len() - 60);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
//! Does a screen declare its own OPAQUE BLACK backdrop? Disc-wide.
|
||||
//!
|
||||
//! `sylpheed-port` observed that the splash builds declare `palogo_eff0.prm` as a
|
||||
//! full-screen primitive at t=0 with `fade_argb 0xff000000` -- alpha 255 over RGB
|
||||
//! 000000 -- and turned it into a candidate predicate: a declared opaque-black
|
||||
//! backdrop separates STANDALONE screens from COMPOSITED ones. On their sixteen
|
||||
//! exported screens it splits 12 / 4, with all four exceptions independently known
|
||||
//! to be composited (the two `press_start` plates, and two loading builds that
|
||||
//! carry the `pgloading_*` set without its backdrop).
|
||||
//!
|
||||
//! That matters because the corpus previously told them "no content rule exists,
|
||||
//! take the entry index" -- correct for the question asked (recognise the splash),
|
||||
//! but this is a content rule for a different and useful question. They asked for
|
||||
//! it to be tested against an archive they do not have. This is that test.
|
||||
//!
|
||||
//! CONTROL: it must reproduce the 12/4 split on GP_TITLE's sixteen composable
|
||||
//! bundles before its disc-wide numbers mean anything.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example black_backdrop_predicate
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A screen declares its own backdrop if some `.prm` primitive holds
|
||||
/// `fade == 0xff000000` at t = 0: full alpha over black.
|
||||
fn has_black_backdrop(b: &ui_layout::UiBuild) -> Option<String> {
|
||||
for el in &b.elements {
|
||||
if !el.name.ends_with(".prm") { continue }
|
||||
if let Some(k) = el.keyframes.iter().find(|k| k.time == Some(0)) {
|
||||
if k.fade == 0xff00_0000 { return Some(el.name.clone()) }
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
|
||||
println!("== CONTROL: GP_TITLE's 16 composable bundles (port reports 12 with, 4 without)");
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
let (mut y, mut n) = (0, 0);
|
||||
for e in 0..16usize {
|
||||
let Ok(by) = ar.read(&ar.entries()[e]) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
match has_black_backdrop(&b) {
|
||||
Some(nm) => { y += 1; println!(" entry {e:>2} YES {nm}") }
|
||||
None => { n += 1; println!(" entry {e:>2} no") }
|
||||
}
|
||||
}
|
||||
println!(" -> {y} with, {n} without\n");
|
||||
|
||||
println!("== DISC-WIDE, over every screen build");
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
|
||||
paks.sort();
|
||||
let (mut tot, mut with) = (0usize, 0usize);
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
let name = pak.file_name().unwrap().to_string_lossy().to_string();
|
||||
let (mut t, mut w) = (0usize, 0usize);
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ui_layout::is_build(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
t += 1;
|
||||
if has_black_backdrop(&b).is_some() { w += 1 }
|
||||
}
|
||||
if t > 0 {
|
||||
println!("{name:30} {w:4} / {t:<4} declare a black backdrop");
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
tot += t; with += w;
|
||||
}
|
||||
println!("\n{with} of {tot} screen builds disc-wide declare an opaque-black backdrop \
|
||||
({:.1} %)", 100.0 * with as f64 / tot as f64);
|
||||
println!("--- END ---");
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
//! Control for the new public accessor `ui_layout::sprite_blend_additive`.
|
||||
//!
|
||||
//! `blend_vs_t8ad_bit` established the field by reading the `T8aD` header inline.
|
||||
//! The exporter cannot do that — `Element` exposed nothing at `+0x04`, which is
|
||||
//! why a blend map keyed by SCREEN NAME had to be authored, and why the Japanese
|
||||
//! menus were being asserted-by-omission to blend differently from the English
|
||||
//! ones. This checks the accessor the exporter will actually call, against the
|
||||
//! same 35 oracle rows, so a later refactor cannot silently change the field.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example blend_api_check
|
||||
use std::path::PathBuf;
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
|
||||
/// (build entry, sprite, measured additive?) — from `data/blend-bit-vs-oracle.txt`,
|
||||
/// every label an `RB_BLENDCONTROL0` value read out of the guest command stream.
|
||||
const MEASURED: &[(usize, &str, bool)] = &[
|
||||
(4, "ptbase2.t32", false), (4, "ptlogo1.t32", false), (4, "ptlogo2.t32", false),
|
||||
(4, "ptlogo_tm.t32", false), (4, "ptcopyright.t32", false),
|
||||
(4, "ptlogo_back2.t32", false), (4, "ptlogo_back2eff.t32", false),
|
||||
(2, "ptbtn00.t32", false), (2, "ptbtn00f.t32", true),
|
||||
(5, "ptbase.t32", false), (5, "ptmsg.t32", false), (5, "ptbtn01f.t32", false),
|
||||
(5, "ptbtneff01.t32", false), (5, "pteff10.t32", true), (5, "pteff12.t32", true),
|
||||
(6, "pteff21.t32", true), (6, "pteff22.t32", true), (6, "pteff23.t32", true),
|
||||
(6, "ptframe3.t32", true), (6, "ptframe4.t32", true),
|
||||
(6, "pteff03.t32", true), (6, "pteff03a.t32", true),
|
||||
];
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
let (mut ok, mut bad, mut missing) = (0, 0, 0);
|
||||
println!("{:<7} {:<22} {:<10} {:<10} {}", "entry", "sprite", "expected", "accessor", "");
|
||||
for e in [2usize, 4, 5, 6] {
|
||||
let by = ar.read(&ar.entries()[e]).expect("entry");
|
||||
let b = ui_layout::parse_build(&by).expect("build");
|
||||
for &(oe, name, additive) in MEASURED {
|
||||
if oe != e { continue }
|
||||
// Prefer the Element accessor; fall back to the by-name one for
|
||||
// focused variants, which are reached through `opt ` and carry no
|
||||
// top-level element of their own.
|
||||
let got = b.elements.iter().find(|x| x.sprite.as_deref() == Some(name))
|
||||
.and_then(|el| ui_layout::sprite_blend_additive(&b, &by, el))
|
||||
.or_else(|| ui_layout::blend_additive_by_name(&b, &by, name));
|
||||
if got.is_none() { println!("{e:<7} {name:<22} {additive:<10} {:<10} MISSING", "-"); missing += 1; continue }
|
||||
match got {
|
||||
Some(g) if g == additive => { ok += 1;
|
||||
println!("{e:<7} {name:<22} {additive:<10} {g:<10} OK"); }
|
||||
other => { bad += 1;
|
||||
println!("{e:<7} {name:<22} {additive:<10} {other:?} MISMATCH"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\n{ok} agree, {bad} mismatched, {missing} not found (of {})", MEASURED.len());
|
||||
// The control that removes the test's own subject: the accessor must also
|
||||
// report a MIX. An accessor stuck at one value would pass every `false` row.
|
||||
let by = ar.read(&ar.entries()[6]).expect("entry");
|
||||
let b = ui_layout::parse_build(&by).expect("build");
|
||||
let add = b.elements.iter().filter(|e| ui_layout::sprite_blend_additive(&b, &by, e) == Some(true)).count();
|
||||
let over = b.elements.iter().filter(|e| ui_layout::sprite_blend_additive(&b, &by, e) == Some(false)).count();
|
||||
println!("control -- entry 6 must report BOTH values: additive={add} alpha-over={over}");
|
||||
assert!(add > 0 && over > 0, "accessor is not discriminating");
|
||||
assert_eq!(bad, 0, "the public accessor disagrees with the committed oracle");
|
||||
println!("PASS");
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//! A PREDICTION, written before the capture that tests it.
|
||||
//!
|
||||
//! `blend_vs_t8ad_bit` finds that `T8aD +0x04` bit `0x02` separates additive from
|
||||
//! alpha-over on all 35 elements whose blend has been measured off the GPU, and
|
||||
//! that no other bit of the 48-byte header does. That is a fit to three screens.
|
||||
//!
|
||||
//! The developer splash (`GP_TITLE` entries 10 and 13) has **never been captured**
|
||||
//! and is one of the five screens the port ships. This prints what the bit says
|
||||
//! its elements should be, so the capture can falsify it rather than confirm it.
|
||||
//!
|
||||
//! Takes an archive and a build list, so the prediction can be written for any
|
||||
//! screen -- including one in a DIFFERENT pak, which is the sharper test: the
|
||||
//! splash predicts alpha-over for both its elements and so can only fail, never
|
||||
//! discriminate, while a screen with a predicted MIX can do both.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example blend_prediction_splash
|
||||
//! cargo run -p sylpheed-formats --example blend_prediction_splash -- GP_OPTIONS 0 1 2
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let pak = args.iter().find(|a| a.parse::<usize>().is_err())
|
||||
.cloned().unwrap_or_else(|| "GP_TITLE".to_string());
|
||||
let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak");
|
||||
let builds: Vec<usize> = args.iter().filter_map(|a| a.parse().ok()).collect();
|
||||
let builds = if builds.is_empty() { (0..ar.entries().len()).collect() } else { builds };
|
||||
for e in builds {
|
||||
let Ok(by) = ar.read(&ar.entries()[e]) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
println!("=== {pak} entry {e} ===");
|
||||
let mut names: Vec<&String> = b.sprites.keys().collect();
|
||||
names.sort();
|
||||
for n in names {
|
||||
let (off, size) = b.sprites[n];
|
||||
let s = &by[off..(off + size).min(by.len())];
|
||||
if s.len() < 8 || &s[0..4] != b"T8aD" { continue }
|
||||
let w = u32::from_be_bytes([s[4], s[5], s[6], s[7]]);
|
||||
println!("{n:<26} +0x04 = {w:08X} bit 0x02 {} PREDICT {}",
|
||||
if w & 2 != 0 { "SET " } else { "clear" },
|
||||
if w & 2 != 0 { "ADDITIVE" } else { "alpha-over" });
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
//! Does `T8aD +0x04` bit `0x02` predict the blend the GAME uses?
|
||||
//!
|
||||
//! ⚠️ **`REFUTED.md` kills this claim**: *"`T8aD +0x04` bit `0x02` selects an
|
||||
//! additive blend" → mine, and refuted. Blending those sprites additively
|
||||
//! worsens every measure against the capture.* That refutation rests entirely on
|
||||
//! **our renderer** — it is a claim about our renderer, and the corpus's own rule
|
||||
//! says so. Since it was written, the blend has been measured off the GPU per
|
||||
//! draw on three screens (`structures/ui-blend-mode-measured.md`), so the claim
|
||||
//! can now be tested against the oracle instead of against a render.
|
||||
//!
|
||||
//! The labels below are **not** from a render. Every one is a
|
||||
//! `RB_BLENDCONTROL0` value read out of the guest command stream and attributed
|
||||
//! to an element by quad size:
|
||||
//! `data/ui-blend-mode-measured.txt`, `data/ui-blend-title-and-replication.txt`,
|
||||
//! `data/ui-blend-extras-complete.txt`.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example blend_vs_t8ad_bit
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// (build entry, sprite, measured additive?) — the oracle's verdicts, verbatim.
|
||||
const MEASURED: &[(usize, &str, bool)] = &[
|
||||
// --- GP_TITLE entry 4 + 2, the live title -------------------------------
|
||||
(4, "ptbase2.t32", false),
|
||||
(4, "ptlogo1.t32", false),
|
||||
(4, "ptlogo2.t32", false),
|
||||
(4, "ptlogo_tm.t32", false),
|
||||
(4, "ptcopyright.t32", false),
|
||||
(4, "ptlogo_back2.t32", false),
|
||||
(4, "ptlogo_back2eff.t32", false),
|
||||
(2, "ptbtn00.t32", false),
|
||||
(2, "ptbtn00f.t32", true),
|
||||
// --- entry 5, the main menu ---------------------------------------------
|
||||
(5, "ptbase.t32", false),
|
||||
(5, "ptmsg.t32", false),
|
||||
(5, "ptbtn01f.t32", false),
|
||||
(5, "ptbtneff01.t32", false),
|
||||
(5, "pteff10.t32", true),
|
||||
(5, "pteff12.t32", true),
|
||||
(5, "ptframe1.t32", true),
|
||||
(5, "ptframe2.t32", true),
|
||||
(5, "pteff03.t32", true), // the rotated sweep strips, via ptloop01/02
|
||||
(5, "pteff03a.t32", true),
|
||||
// --- entry 6, EXTRAS ------------------------------------------------------
|
||||
(6, "ptbase.t32", false),
|
||||
(6, "ptmsg2.t32", false),
|
||||
(6, "pttitle.t32", false),
|
||||
(6, "ptbtn11f.t32", false),
|
||||
(6, "ptbtn12.t32", false),
|
||||
(6, "ptbtn13.t32", false),
|
||||
(6, "ptbtneff02.t32", false),
|
||||
(6, "pteff10.t32", true),
|
||||
(6, "pteff20.t32", true),
|
||||
(6, "pteff21.t32", true),
|
||||
(6, "pteff22.t32", true),
|
||||
(6, "pteff23.t32", true),
|
||||
(6, "ptframe3.t32", true),
|
||||
(6, "ptframe4.t32", true),
|
||||
(6, "pteff03.t32", true),
|
||||
(6, "pteff03a.t32", true),
|
||||
];
|
||||
|
||||
fn main() {
|
||||
if std::env::args().any(|a| a == "decl") { decl_rivals(); return }
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
let mut hdr: BTreeMap<(usize, String), u32> = BTreeMap::new();
|
||||
for e in [2usize, 4, 5, 6] {
|
||||
let by = ar.read(&ar.entries()[e]).expect("entry");
|
||||
let b = ui_layout::parse_build(&by).expect("build");
|
||||
for (n, &(off, size)) in &b.sprites {
|
||||
let s = &by[off..(off + size).min(by.len())];
|
||||
if s.len() < 8 || &s[0..4] != b"T8aD" { continue }
|
||||
hdr.insert((e, n.clone()), u32::from_be_bytes([s[4], s[5], s[6], s[7]]));
|
||||
}
|
||||
}
|
||||
println!("{:<10} {:<22} {:<10} {:>10} {}", "entry", "sprite", "+0x04", "bit 0x02", "measured blend");
|
||||
let (mut tp, mut tn, mut fp, mut fnn, mut missing) = (0, 0, 0, 0, 0);
|
||||
for &(e, n, additive) in MEASURED {
|
||||
let Some(&w) = hdr.get(&(e, n.to_string())) else {
|
||||
println!("{e:<10} {n:<22} {:<10} {:>10} {}", "MISSING", "-",
|
||||
if additive { "ADDITIVE" } else { "alpha-over" });
|
||||
missing += 1;
|
||||
continue;
|
||||
};
|
||||
let bit = w & 0x02 != 0;
|
||||
match (bit, additive) {
|
||||
(true, true) => tp += 1,
|
||||
(false, false) => tn += 1,
|
||||
(true, false) => fp += 1,
|
||||
(false, true) => fnn += 1,
|
||||
}
|
||||
println!("{e:<10} {n:<22} {:08X} {:>10} {}{}", w, bit,
|
||||
if additive { "ADDITIVE" } else { "alpha-over" },
|
||||
if bit == additive { "" } else { " <== DISAGREES" });
|
||||
}
|
||||
println!("\nbit set & additive {tp}");
|
||||
println!("bit clear & alpha-over {tn}");
|
||||
println!("bit set & alpha-over {fp} <- false positives");
|
||||
println!("bit clear & additive {fnn} <- false negatives");
|
||||
println!("sprite not found {missing}");
|
||||
println!("\n{}", if fp == 0 && fnn == 0 && missing == 0 {
|
||||
"PERFECT PARTITION on every element whose blend was measured."
|
||||
} else {
|
||||
"THE BIT DOES NOT PREDICT THE MEASURED BLEND."
|
||||
});
|
||||
|
||||
// ── THE CONTROL THAT MATTERS ────────────────────────────────────────────
|
||||
// A perfect partition is worthless if half the header partitions equally
|
||||
// well: then the sample is too small to single out a field, and picking
|
||||
// `+0x04` bit 0x02 out of the tie is the same mistake as picking `+0x08`
|
||||
// 0x8050 was. So: how many OTHER bits of the first 12 header words separate
|
||||
// the same 35 elements without error?
|
||||
let mut rivals: Vec<String> = Vec::new();
|
||||
let mut words: BTreeMap<(usize, String), Vec<u32>> = BTreeMap::new();
|
||||
for e in [2usize, 4, 5, 6] {
|
||||
let by = ar.read(&ar.entries()[e]).expect("entry");
|
||||
let b = ui_layout::parse_build(&by).expect("build");
|
||||
for (n, &(off, size)) in &b.sprites {
|
||||
let s = &by[off..(off + size).min(by.len())];
|
||||
if s.len() < 48 || &s[0..4] != b"T8aD" { continue }
|
||||
words.insert((e, n.clone()), (0..12)
|
||||
.map(|k| u32::from_be_bytes([s[k*4], s[k*4+1], s[k*4+2], s[k*4+3]]))
|
||||
.collect());
|
||||
}
|
||||
}
|
||||
for w in 0..12 {
|
||||
for bit in 0..32 {
|
||||
let mut ok = true;
|
||||
let mut set_seen = false;
|
||||
let mut clear_seen = false;
|
||||
for &(e, n, additive) in MEASURED {
|
||||
let Some(v) = words.get(&(e, n.to_string())) else { ok = false; break };
|
||||
let on = (v[w] >> bit) & 1 == 1;
|
||||
if on { set_seen = true } else { clear_seen = true }
|
||||
if on != additive { ok = false; break }
|
||||
}
|
||||
// A constant bit trivially "agrees" with nothing; require both sides.
|
||||
if ok && set_seen && clear_seen {
|
||||
rivals.push(format!("+0x{:02X} bit {bit} (0x{:X})", w * 4, 1u32 << bit));
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\nRIVAL FIELDS — other bits of the first 12 header words that separate");
|
||||
println!("the same 35 elements with zero errors: {}", rivals.len());
|
||||
for r in &rivals { println!(" {r}"); }
|
||||
if rivals.len() == 1 {
|
||||
println!(" -> the sample singles out ONE field. Nothing else in the header does it.");
|
||||
} else {
|
||||
println!(" -> the sample does NOT single out a field; {} candidates tie.", rivals.len());
|
||||
}
|
||||
}
|
||||
|
||||
// ── An integrity check the published decode did NOT do ──────────────────────
|
||||
// The rival sweep above covers the 48-byte T8aD header. It does NOT cover the
|
||||
// 60-byte DECLARATION entry, and the earlier declaration hunt was run with
|
||||
// labels taken from the port's RENDER -- which put pteff10, pteff12, pteff20 and
|
||||
// pteff21..23 on the alpha-over side, where the oracle says all six are
|
||||
// additive. So the declaration has never been swept with correct labels, and if
|
||||
// one of its words also partitions the 35 without error, "the field is the T8aD
|
||||
// bit" is underdetermined.
|
||||
//
|
||||
// Run as: cargo run -p sylpheed-formats --example blend_vs_t8ad_bit -- decl
|
||||
#[allow(dead_code)]
|
||||
fn decl_rivals() {
|
||||
use std::collections::BTreeMap;
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
const AT: usize = 0x20;
|
||||
const STRIDE: usize = 60;
|
||||
let mut decl: BTreeMap<(usize, String), Vec<u32>> = BTreeMap::new();
|
||||
for e in [2usize, 4, 5, 6] {
|
||||
let Ok(by) = ar.read(&ar.entries()[e]) else { continue };
|
||||
if by.len() < 0x18 { continue }
|
||||
let count = u32::from_be_bytes([by[0x14], by[0x15], by[0x16], by[0x17]]) as usize;
|
||||
for i in 0..count {
|
||||
let at = AT + i * STRIDE;
|
||||
if at + STRIDE > by.len() { break }
|
||||
let end = by[at..at + 12].iter().position(|&c| c == 0).unwrap_or(12);
|
||||
let name = String::from_utf8_lossy(&by[at..at + end]).to_string();
|
||||
decl.insert((e, name), (0..15)
|
||||
.map(|k| u32::from_be_bytes([by[at+k*4], by[at+k*4+1], by[at+k*4+2], by[at+k*4+3]]))
|
||||
.collect());
|
||||
}
|
||||
}
|
||||
let mut missing: Vec<String> = Vec::new();
|
||||
for &(e, n, _) in MEASURED {
|
||||
if !decl.contains_key(&(e, n.to_string())) {
|
||||
missing.push(format!("entry {e} {n}"));
|
||||
}
|
||||
}
|
||||
println!("\n=== DECLARATION-ENTRY RIVAL SWEEP ===");
|
||||
println!("measured elements with NO declaration entry of their own: {} of {}",
|
||||
missing.len(), MEASURED.len());
|
||||
for m in &missing { println!(" {m}"); }
|
||||
if !missing.is_empty() {
|
||||
println!(" -> no declaration field can select the blend for these, because they");
|
||||
println!(" have no declaration entry. The header is the only per-sprite home.");
|
||||
}
|
||||
let labelled: Vec<&(usize, &str, bool)> = MEASURED.iter()
|
||||
.filter(|(e, n, _)| decl.contains_key(&(*e, n.to_string()))).collect();
|
||||
let mut rivals = 0;
|
||||
for w in 0..15 {
|
||||
for bit in 0..32 {
|
||||
let (mut ok, mut s, mut c) = (true, false, false);
|
||||
for &&(e, n, additive) in &labelled {
|
||||
let on = (decl[&(e, n.to_string())][w] >> bit) & 1 == 1;
|
||||
if on { s = true } else { c = true }
|
||||
if on != additive { ok = false; break }
|
||||
}
|
||||
if ok && s && c {
|
||||
println!(" RIVAL: declaration +0x{:02X} bit {bit}", w * 4);
|
||||
rivals += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("declaration bits that separate the {} labellable elements: {rivals}",
|
||||
labelled.len());
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//! Does a textured element's declaration carry anything that distinguishes the
|
||||
//! two FRAME elements from every other element on the main menu?
|
||||
//!
|
||||
//! `sylpheed-port` asks for the blend/alpha mode of `ptframe1`/`ptframe2`. Prior
|
||||
//! work is on `.prm` PRIMITIVES (`ui-prm-blend-mode.md`, undecodable with reach —
|
||||
//! no field, the declaration words are constant) and on a refuted `T8aD +0x04`
|
||||
//! bit. Neither covers a `.t32` element's own declaration entry, which is 60
|
||||
//! bytes and mostly unread.
|
||||
//!
|
||||
//! This dumps every declaration entry on the menu and reports, per 4-byte word,
|
||||
//! whether the two frames share a value that no other element has. A word that
|
||||
//! separates exactly those two is a candidate; one that does not, is not.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example decl_entry_diff
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const AT: usize = 0x20;
|
||||
const N: usize = 60;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
let by = ar.read(&ar.entries()[5]).expect("entry 5");
|
||||
let b = ui_layout::parse_build(&by).expect("build");
|
||||
let names: Vec<String> = b.elements.iter().map(|e| e.name.clone()).collect();
|
||||
let frames: Vec<usize> = names.iter().enumerate()
|
||||
.filter(|(_, n)| n.starts_with("ptframe")).map(|(i, _)| i).collect();
|
||||
println!("{} elements; frames at indices {:?}", names.len(), frames);
|
||||
|
||||
let word = |i: usize, w: usize| -> u32 {
|
||||
let o = AT + i * N + w * 4;
|
||||
u32::from_be_bytes([by[o], by[o + 1], by[o + 2], by[o + 3]])
|
||||
};
|
||||
println!("\nper-word: does a value separate EXACTLY the two frames?");
|
||||
for w in 0..N / 4 {
|
||||
let fv: Vec<u32> = frames.iter().map(|&i| word(i, w)).collect();
|
||||
let same_in_frames = fv.windows(2).all(|p| p[0] == p[1]);
|
||||
let others: Vec<u32> = (0..names.len()).filter(|i| !frames.contains(i))
|
||||
.map(|i| word(i, w)).collect();
|
||||
let unique = same_in_frames && !others.contains(&fv[0]);
|
||||
let distinct = { let mut v: Vec<u32> = (0..names.len()).map(|i| word(i, w)).collect();
|
||||
v.sort_unstable(); v.dedup(); v.len() };
|
||||
println!(" +0x{:02X} frames {:?} distinct values {distinct:2}{}",
|
||||
w * 4, fv.iter().map(|v| format!("{v:08X}")).collect::<Vec<_>>(),
|
||||
if unique { " <- SEPARATES THE FRAMES" } else { "" });
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
//! Which elements carry which values at the low-cardinality declaration words?
|
||||
//!
|
||||
//! `decl_entry_diff` found nothing separating `ptframe1`/`ptframe2` except their
|
||||
//! NAME — +0x00 and +0x08 are the name string ("ptfr", ".t32"), so those two hits
|
||||
//! are a false positive of that test, not a field.
|
||||
//!
|
||||
//! The remaining candidates for a per-element mode flag are the words with few
|
||||
//! distinct values: +0x28 (3) and +0x2C (6). This prints who has what.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example decl_flag_words
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const AT: usize = 0x20;
|
||||
const N: usize = 60;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
let by = ar.read(&ar.entries()[5]).expect("entry 5");
|
||||
let b = ui_layout::parse_build(&by).expect("build");
|
||||
let w = |i: usize, off: usize| -> u32 {
|
||||
let o = AT + i * N + off;
|
||||
u32::from_be_bytes([by[o], by[o + 1], by[o + 2], by[o + 3]])
|
||||
};
|
||||
println!("{:<22} {:>10} {:>10} {:>10} {:>10}", "element", "+0x28", "+0x2C", "+0x34", "kind");
|
||||
for (i, e) in b.elements.iter().enumerate() {
|
||||
let mark = if e.name.starts_with("ptframe") { " <- FRAME" } else { "" };
|
||||
println!("{:<22} {:>10} {:>10} {:>10} {:>#10x}{mark}",
|
||||
e.name, w(i, 0x28), w(i, 0x2C) as i32, w(i, 0x34), e.kind);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
//! How often is a screen's design size READ, and how often is it FABRICATED?
|
||||
//!
|
||||
//! `ui_layout.rs` scans the `.rat` records for a `(w,h)` at `+0x18`/`+0x1c` and,
|
||||
//! finding none, falls back to `(DESIGN_W, DESIGN_H)` = 1280x720. Its own comment
|
||||
//! says "every screen seen is 1280x720, **which is also the fallback**" -- which
|
||||
//! is precisely the problem: the fabricated value equals the expected one, so no
|
||||
//! output of the parser can distinguish a read design size from an invented one.
|
||||
//! The port sizes its screens off this number.
|
||||
//!
|
||||
//! This replicates the scan through the public RATC API and counts.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example design_size_fallback
|
||||
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn be32(b: &[u8], o: usize) -> u32 {
|
||||
if o + 4 > b.len() { return 0 }
|
||||
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
|
||||
paks.sort();
|
||||
let (mut read, mut fell_back, mut nonstd) = (0usize, 0usize, 0usize);
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
let name = pak.file_name().unwrap().to_string_lossy().to_string();
|
||||
let (mut r, mut f) = (0usize, 0usize);
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ui_layout::is_build(&by) { continue }
|
||||
let Some(kids) = ratc::parse(&by) else { continue };
|
||||
// the same predicate ui_layout uses, over the same records
|
||||
// ⚠️ A first version took EVERY RATC child and failed its control:
|
||||
// it reported all 965 builds stating a non-1280x720 size, where
|
||||
// `screen list` prints 1280x720 for every one. `records` in
|
||||
// ui_layout is the `.rat` children only; a T8aD sprite header read
|
||||
// at +0x18 is garbage that passes the range test.
|
||||
let found = kids.iter().filter(|k| k.kind == "RATC" || k.name.ends_with(".rat")).find_map(|k| {
|
||||
let rec = &by[k.offset..(k.offset + k.size).min(by.len())];
|
||||
let (w, h) = (be32(rec, 0x18), be32(rec, 0x1c));
|
||||
(w > 0 && h > 0 && w <= 8192 && h <= 8192).then_some((w, h))
|
||||
});
|
||||
match found {
|
||||
Some((w, h)) => { r += 1; if (w, h) != (1280, 720) { nonstd += 1;
|
||||
println!(" {name} : a build states a NON-standard design size {w}x{h}"); } }
|
||||
None => f += 1,
|
||||
}
|
||||
}
|
||||
if r + f > 0 {
|
||||
println!("{name:30} {r:5} read {f:5} FABRICATED");
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
read += r; fell_back += f;
|
||||
}
|
||||
println!("\n{read} builds state a design size, {fell_back} get the 1280x720 FALLBACK");
|
||||
println!("{nonstd} builds state something other than 1280x720");
|
||||
println!("--- END ---");
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//! Is `GP_DIALOG` entry 2 the `DLG_SELECT_DIFFICULTY` screen?
|
||||
//!
|
||||
//! The image lists `DLG_SELECT_DIFFICULTY` among the `DLG_*` names at
|
||||
//! `0x820A41BB`, so DIFFICULTY is a DIALOG, not a GamePart screen with its own
|
||||
//! pak — which is why a search for an 8-record `btn` build in a difficulty-named
|
||||
//! archive found nothing. `GP_DIALOG` entry 2 carries `pcbtn00`..`03`: four
|
||||
//! buttons, matching EASY / NORMAL / HARD / BACK.
|
||||
//!
|
||||
//! CONTROL: `GP_TITLE` entry 5's five buttons must come back at the rows the disc
|
||||
//! is independently known to place them (162/242/322/401/482, spacing 80).
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example dialog_button_rows
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn rows(ar: &PakArchive, entry: usize, what: &str) {
|
||||
let Ok(by) = ar.read(&ar.entries()[entry]) else { return };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { return };
|
||||
let mut v: Vec<(i32, String)> = b
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
let n = &e.name;
|
||||
(n.starts_with("pcbtn") || n.starts_with("ptbtn")) && !n.contains('f')
|
||||
})
|
||||
.map(|e| (e.rest().map(|k| k.y).unwrap_or(e.pivot_y as i32), e.name.clone()))
|
||||
.collect();
|
||||
v.sort_by_key(|r| r.0);
|
||||
println!("\n{what} (entry {entry}):");
|
||||
for (y, n) in &v {
|
||||
println!(" y {y:5} {n}");
|
||||
}
|
||||
if v.len() > 1 {
|
||||
let sp: Vec<i32> = v.windows(2).map(|w| w[1].0 - w[0].0).collect();
|
||||
println!(" spacing {sp:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let t = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
rows(&t, 5, "CONTROL: GP_TITLE main menu (must be 162/242/322/401/482)");
|
||||
let d = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
|
||||
rows(&d, 2, "GP_DIALOG candidate for DLG_SELECT_DIFFICULTY");
|
||||
rows(&d, 3, "GP_DIALOG entry 3 (the pair)");
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
//! Are the 37 equal-button-count `GP_DIALOG` pairs language pairs, or not?
|
||||
//!
|
||||
//! 26 of 65 adjacent pairs differ in BUTTON COUNT — two languages cannot, so those
|
||||
//! are unrelated dialogs. For the rest my language reading was left UNSUPPORTED
|
||||
//! rather than refuted, and both agents observed that nothing rewards closing it.
|
||||
//!
|
||||
//! A language pair must share its BUTTON NAMES and ROWS exactly (a locale changes
|
||||
//! glyphs, not layout) and differ only elsewhere. An unrelated pair will differ in
|
||||
//! button names or rows too.
|
||||
//!
|
||||
//! CONTROL: entries 2/3 (identical element sets) must come out as "buttons match".
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example dialog_pair_37
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn sig(ar: &PakArchive, i: usize) -> Option<(Vec<(String, i32)>, usize)> {
|
||||
let by = ar.read(&ar.entries()[i]).ok()?;
|
||||
let b = ui_layout::parse_build(&by)?;
|
||||
let mut v: Vec<(String, i32)> = b
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|e| e.name.contains("btn"))
|
||||
.map(|e| (e.name.clone(), e.rest().map(|k| k.y).unwrap_or(e.pivot_y as i32)))
|
||||
.collect();
|
||||
v.sort();
|
||||
let n = v.len();
|
||||
Some((v, n))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
|
||||
let n = ar.entries().len();
|
||||
let (mut match_btn, mut differ_btn, mut ctrl) = (0, 0, false);
|
||||
let mut examples = 0;
|
||||
for k in (0..n).step_by(2) {
|
||||
let (Some((a, na)), Some((b, nb))) = (sig(&ar, k), sig(&ar, k + 1)) else { continue };
|
||||
if na != nb {
|
||||
continue; // the 26 already settled
|
||||
}
|
||||
if a == b {
|
||||
match_btn += 1;
|
||||
if k == 2 { ctrl = true }
|
||||
} else {
|
||||
differ_btn += 1;
|
||||
if examples < 5 {
|
||||
println!(" entries {k:3}/{:<3} buttons DIFFER", k + 1);
|
||||
println!(" {:?}", a.iter().map(|x| &x.0).collect::<Vec<_>>());
|
||||
println!(" {:?}", b.iter().map(|x| &x.0).collect::<Vec<_>>());
|
||||
examples += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\nequal-button-count pairs whose button NAMES+ROWS match : {match_btn}");
|
||||
println!("equal-button-count pairs whose buttons DIFFER : {differ_btn}");
|
||||
println!("control (entries 2/3 counted as matching): {}", if ctrl { "PASSED" } else { "FAILED" });
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//! Do adjacent `GP_DIALOG` entries differ in BUTTON COUNT?
|
||||
//!
|
||||
//! I offered an untested reading for why 63 of 65 adjacent pairs have different
|
||||
//! element sets: dialog text baked into language-specific sprites, so EN/JP
|
||||
//! entries differ by construction. `sylpheed-port` refuted it with a count — two
|
||||
//! languages of one dialog cannot differ in how many buttons they have. This
|
||||
//! re-derives that with my own reader before I record the refutation.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example dialog_pair_button_counts
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn btns(ar: &PakArchive, i: usize) -> Option<usize> {
|
||||
let by = ar.read(&ar.entries()[i]).ok()?;
|
||||
let b = ui_layout::parse_build(&by)?;
|
||||
Some(b.elements.iter().filter(|e| e.name.contains("btn")).count())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
|
||||
let n = ar.entries().len();
|
||||
let (mut diff, mut same, mut skip) = (0, 0, 0);
|
||||
let mut show = 0;
|
||||
for k in (0..n).step_by(2) {
|
||||
match (btns(&ar, k), btns(&ar, k + 1)) {
|
||||
(Some(a), Some(b)) => {
|
||||
if a != b {
|
||||
diff += 1;
|
||||
if show < 6 {
|
||||
println!(" entries {k:3}/{:<3} button counts {a} vs {b}", k + 1);
|
||||
show += 1;
|
||||
}
|
||||
} else {
|
||||
same += 1
|
||||
}
|
||||
}
|
||||
_ => skip += 1,
|
||||
}
|
||||
}
|
||||
println!("\nadjacent pairs differing in BUTTON COUNT: {diff}");
|
||||
println!("adjacent pairs with equal button counts : {same}");
|
||||
println!("unreadable : {skip}");
|
||||
println!("\ntwo languages of one dialog cannot differ in button count.");
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
//! What differs between equal-button-count `GP_DIALOG` adjacent pairs?
|
||||
//!
|
||||
//! All 39 share button names and rows. That is consistent with a LANGUAGE PAIR and
|
||||
//! equally with TWO DIALOGS SHARING A BUTTON TEMPLATE (two yes/no boxes differing
|
||||
//! only in their message sprite). The difference is in what else differs: a
|
||||
//! language pair should differ in the SAME slots with locale-marked names.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example dialog_pair_diffs
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn names(ar: &PakArchive, i: usize) -> Option<BTreeSet<String>> {
|
||||
let by = ar.read(&ar.entries()[i]).ok()?;
|
||||
let b = ui_layout::parse_build(&by)?;
|
||||
Some(b.elements.iter().map(|e| e.name.clone()).collect())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
|
||||
let n = ar.entries().len();
|
||||
let mut shown = 0;
|
||||
for k in (0..n).step_by(2) {
|
||||
let (Some(a), Some(b)) = (names(&ar, k), names(&ar, k + 1)) else { continue };
|
||||
if a == b {
|
||||
continue;
|
||||
}
|
||||
let oa: Vec<&String> = a.difference(&b).collect();
|
||||
let ob: Vec<&String> = b.difference(&a).collect();
|
||||
// only the equal-button-count ones
|
||||
let ba = a.iter().filter(|x| x.contains("btn")).count();
|
||||
let bb = b.iter().filter(|x| x.contains("btn")).count();
|
||||
if ba != bb {
|
||||
continue;
|
||||
}
|
||||
if shown < 6 {
|
||||
println!("entries {k:3}/{:<3} shared {} only-in-{k}: {:?} only-in-{}: {:?}",
|
||||
k + 1, a.intersection(&b).count(), oa, k + 1, ob);
|
||||
shown += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//! Are `GP_DIALOG` entries 0/1 and 2/3 a LANGUAGE PAIR or a DUPLICATE?
|
||||
//!
|
||||
//! They are the only two adjacent pairs in that archive with identical element
|
||||
//! sets; every other adjacent pair is two unrelated dialogs. Left open as
|
||||
//! "untested" — identical element names are equally consistent with a language
|
||||
//! pair (same layout, different glyphs baked into the textures) and with a
|
||||
//! byte-for-byte duplicate.
|
||||
//!
|
||||
//! The bytes decide it: identical entries are a duplicate; entries that share
|
||||
//! every element name but differ in payload are a language pair.
|
||||
//!
|
||||
//! CONTROL: entries 10/11, known to be two DIFFERENT dialogs (stage 10 vs stage
|
||||
//! 02), must come out as differing — and by a lot. A comparator that cannot
|
||||
//! separate two unrelated dialogs cannot judge two similar ones.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example dialog_pair_identity
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn cmp(ar: &PakArchive, a: usize, b: usize, what: &str) {
|
||||
let (Ok(x), Ok(y)) = (ar.read(&ar.entries()[a]), ar.read(&ar.entries()[b])) else {
|
||||
println!("{what}: unreadable");
|
||||
return;
|
||||
};
|
||||
let same_len = x.len() == y.len();
|
||||
let n = x.len().min(y.len());
|
||||
let diff = (0..n).filter(|&i| x[i] != y[i]).count();
|
||||
let first = (0..n).find(|&i| x[i] != y[i]);
|
||||
println!("{what}");
|
||||
println!(" sizes {} / {} ({})", x.len(), y.len(),
|
||||
if same_len { "equal" } else { "DIFFER" });
|
||||
println!(" differing bytes over the common prefix: {diff} / {n} ({:.2}%)",
|
||||
100.0 * diff as f64 / n as f64);
|
||||
match first {
|
||||
None if same_len => println!(" => BYTE-IDENTICAL — a duplicate"),
|
||||
None => println!(" => one is a prefix of the other"),
|
||||
Some(o) => println!(" => first difference at offset 0x{o:X}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
|
||||
cmp(&ar, 10, 11, "CONTROL: entries 10/11 — known two different dialogs");
|
||||
cmp(&ar, 0, 1, "entries 0/1");
|
||||
cmp(&ar, 2, 3, "entries 2/3 — the DIFFICULTY build");
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//! Are `GP_DIALOG`'s 140 entries adjacent EN/JP pairs, one per dialog record?
|
||||
//!
|
||||
//! The dialog table has 70 records and the archive has 140 entries. If the
|
||||
//! pairing is adjacent — (0,1), (2,3), … — then dialog index = entry / 2, and the
|
||||
//! unbound id-to-entry join becomes an ordering question rather than a search.
|
||||
//!
|
||||
//! Test: for each pair, compare the SET of element names. GP_TITLE's EN/JP pairs
|
||||
//! share their sprite sets exactly except for the title art (4/7), so identical
|
||||
//! sets are the signature of a language pair.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example dialog_pairing
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn names(ar: &PakArchive, i: usize) -> Option<BTreeSet<String>> {
|
||||
let by = ar.read(&ar.entries()[i]).ok()?;
|
||||
let b = ui_layout::parse_build(&by)?;
|
||||
Some(b.elements.iter().map(|e| e.name.clone()).collect())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
|
||||
let n = ar.entries().len();
|
||||
let (mut adj_same, mut adj_diff, mut skipped) = (0, 0, 0);
|
||||
for k in (0..n).step_by(2) {
|
||||
match (names(&ar, k), names(&ar, k + 1)) {
|
||||
(Some(a), Some(b)) => {
|
||||
if a == b { adj_same += 1; println!(" identical pair: entries {k}/{}", k+1) } else { adj_diff += 1 }
|
||||
}
|
||||
_ => skipped += 1,
|
||||
}
|
||||
}
|
||||
println!("ADJACENT pairing (2k, 2k+1): identical {adj_same} differing {adj_diff} unreadable {skipped}");
|
||||
// rival hypothesis: halves, (i, i+70)
|
||||
let (mut h_same, mut h_diff, mut h_skip) = (0, 0, 0);
|
||||
for k in 0..n / 2 {
|
||||
match (names(&ar, k), names(&ar, k + n / 2)) {
|
||||
(Some(a), Some(b)) => {
|
||||
if a == b { h_same += 1 } else { h_diff += 1 }
|
||||
}
|
||||
_ => h_skip += 1,
|
||||
}
|
||||
}
|
||||
println!("HALVES pairing (i, i+70): identical {h_same} differing {h_diff} unreadable {h_skip}");
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
//! How is `GP_DIALOG.pak` organised? Testing whether dialog id maps positionally.
|
||||
//!
|
||||
//! The dialog table gives name -> id (70 records, `DLG_SELECT_DIFFICULTY` = 2000)
|
||||
//! and the disc gives a unique four-button build at GP_DIALOG entries 2/3. Nothing
|
||||
//! joins them. If the archive were laid out in table order, or in id order, the
|
||||
//! join would be positional — this checks.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example dialog_pak_shape
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
|
||||
let n = ar.entries().len();
|
||||
let mut builds = 0;
|
||||
let mut with_btn = 0;
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if let Some(b) = ui_layout::parse_build(&by) {
|
||||
builds += 1;
|
||||
if b.elements.iter().any(|el| el.name.contains("btn")) {
|
||||
with_btn += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("entries {n}, parse as builds {builds}, of those with a btn element {with_btn}");
|
||||
println!("dialog table has 70 records; 70 x 2 (EN/JP) = 140");
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//! Is `ptbtn11` the TOP item of the `EXTRAS` screen?
|
||||
//!
|
||||
//! `sylpheed-port` authors `extras/initial_focus: ptbtn11` and states it is
|
||||
//! correct under the surviving reading — "a submenu resets to the item it opens
|
||||
//! on". The oracle shows EXTRAS opening on `MISSION SELECT`, the first of
|
||||
//! MISSION SELECT / MOVIE THEATER / BACK. So their value is right only if
|
||||
//! `ptbtn11` is that first item. This checks it against the disc.
|
||||
//!
|
||||
//! CONTROL: the same read on the MAIN MENU build, whose five buttons have a known
|
||||
//! top-to-bottom order (NEW GAME first). If the ordering rule cannot reproduce a
|
||||
//! known screen it cannot be trusted on an unknown one.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example extras_button_order
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn report(ar: &PakArchive, entry: usize, what: &str) {
|
||||
let Ok(by) = ar.read(&ar.entries()[entry]) else { return };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { return };
|
||||
let mut rows: Vec<(i32, String, u32)> = b
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|e| e.name.starts_with("ptbtn") && !e.name.contains('f'))
|
||||
.map(|e| {
|
||||
let y = e.rest().map(|k| k.y).unwrap_or(e.pivot_y as i32);
|
||||
(y, e.name.clone(), e.kind)
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by_key(|r| r.0);
|
||||
println!("\n{what} (entry {entry}) — buttons top to bottom:");
|
||||
for (y, n, k) in &rows {
|
||||
println!(" y {y:5} {n:14} kind 0x{k:04x}");
|
||||
}
|
||||
if let Some((_, first, _)) = rows.first() {
|
||||
println!(" => TOP item is {first}");
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
report(&ar, 5, "CONTROL: main menu (NEW GAME must be top)");
|
||||
report(&ar, 6, "EXTRAS");
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
//! Where does the `DIFFICULTY` screen live?
|
||||
//!
|
||||
//! `boot-config-and-gamepart-registry.md` records a count-match — "Ⓑ = event 0,
|
||||
//! four menu items load an external archive, EXTRAS stays inside GP_TITLE" —
|
||||
//! explicitly as an observation, not a decode. The disc can test half of it:
|
||||
//! OPTIONS, LOAD GAME and TUTORIAL have their own paks, and EXTRAS' two items
|
||||
//! have GP_MISSION_SELECT / GP_MOVIE_THEATER while EXTRAS itself is GP_TITLE
|
||||
//! entries 6/9. NEW GAME is the fourth, and there is no GP_DIFFICULTY.pak.
|
||||
//!
|
||||
//! So: which archive holds a build with EASY / NORMAL / HARD buttons?
|
||||
//!
|
||||
//! CONTROL: the same scan must find the EXTRAS build in GP_TITLE, whose location
|
||||
//! is independently known (entries 6/9, buttons ptbtn11/12/13).
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example find_difficulty_build
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let dat = root.join("dat");
|
||||
let mut paks: Vec<_> = std::fs::read_dir(&dat)
|
||||
.expect("dat")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
|
||||
.collect();
|
||||
paks.sort();
|
||||
|
||||
let mut found_extras = false;
|
||||
for p in &paks {
|
||||
let Ok(ar) = PakArchive::open(p) else { continue };
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let btns: Vec<&String> = b
|
||||
.records
|
||||
.keys()
|
||||
.filter(|n| n.starts_with("ptbtn") || n.contains("btn"))
|
||||
.collect();
|
||||
if btns.len() != 8 {
|
||||
continue;
|
||||
}
|
||||
let name = p.file_name().unwrap().to_string_lossy();
|
||||
// CONTROL: the known MAIN MENU build (11 records, so this control is now vacuous) must show up.
|
||||
if name == "GP_TITLE.pak" && (i == 5 || i == 8) {
|
||||
found_extras = true;
|
||||
println!("CONTROL {name} entry {i}: {} button records — the known MAIN MENU build (11 records, so this control is now vacuous)",
|
||||
btns.len());
|
||||
}
|
||||
// any build outside GP_TITLE with a small button set is a candidate
|
||||
if name != "GP_TITLE.pak" {
|
||||
let mut names: Vec<String> = btns.iter().map(|s| (*s).clone()).collect();
|
||||
names.sort();
|
||||
println!(" {name:28} entry {i:3} {} buttons {:?}", btns.len(),
|
||||
&names[..names.len().min(8)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\ncontrol {} — the known MAIN MENU build (11 records, so this control is now vacuous) was {}found",
|
||||
if found_extras { "PASSED" } else { "FAILED" },
|
||||
if found_extras { "" } else { "NOT " });
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
//! Where is the `DIFFICULTY` screen? Search by NAME, not by structure.
|
||||
//!
|
||||
//! A previous pass searched every pak for a build with exactly 8 `btn`-named
|
||||
//! records, on the assumption that DIFFICULTY's four items (EASY / NORMAL / HARD
|
||||
//! / BACK) pair with `f` focus variants the way GP_TITLE's screens do. Nothing
|
||||
//! plausible turned up, and the assumption was mine — recorded as a negative
|
||||
//! narrower than "not found" (data/gp-title-holds-three-button-screens.txt).
|
||||
//!
|
||||
//! This drops the structural assumption and looks for the words instead, across
|
||||
//! every sprite AND record name in every build on the disc.
|
||||
//!
|
||||
//! CONTROL: the same scan must find `ptbtn11` in GP_TITLE — a name whose home is
|
||||
//! independently known — when asked for it. A name scan that finds nothing
|
||||
//! proves nothing unless it can find something.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example find_difficulty_names
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const WANTED: &[&str] = &["easy", "normal", "hard", "diff", "level", "rank"];
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<_> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
|
||||
.collect();
|
||||
paks.sort();
|
||||
|
||||
let mut control = false;
|
||||
let mut hits = 0usize;
|
||||
for p in &paks {
|
||||
let Ok(ar) = PakArchive::open(p) else { continue };
|
||||
let pname = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let all: Vec<String> = b
|
||||
.sprites
|
||||
.keys()
|
||||
.chain(b.records.keys())
|
||||
.cloned()
|
||||
.collect();
|
||||
if pname == "GP_TITLE.pak" && all.iter().any(|n| n.contains("ptbtn11")) {
|
||||
control = true;
|
||||
}
|
||||
let m: Vec<&String> = all
|
||||
.iter()
|
||||
.filter(|n| {
|
||||
let l = n.to_lowercase();
|
||||
WANTED.iter().any(|w| l.contains(w))
|
||||
})
|
||||
.collect();
|
||||
if !m.is_empty() {
|
||||
hits += 1;
|
||||
let mut s: Vec<String> = m.iter().map(|x| (*x).clone()).collect();
|
||||
s.sort();
|
||||
s.dedup();
|
||||
println!(" {pname:28} entry {i:3} {:?}", &s[..s.len().min(6)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\ncontrol (found ptbtn11 in GP_TITLE): {}", if control { "PASSED" } else { "FAILED" });
|
||||
println!("{hits} build(s) carried a difficulty-ish name");
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
//! Which cue owns an XMA stream of a given payload size?
|
||||
//!
|
||||
//! A boot with `--xma_param_probe` logs each decoded stream's `byte_size`. Three
|
||||
//! of the five on the take-2 `ADV` boot are that movie's own streams; two —
|
||||
//! 1 150 976 and 1 269 760 B — belong to something unidentified. The probe gives
|
||||
//! a size and nothing else, so the disc has to be asked which cue has a stream
|
||||
//! that long.
|
||||
//!
|
||||
//! Searches every inter-descriptor span of the continuous voice stream, and
|
||||
//! every `sound.pak` entry, for a stream whose payload matches.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example find_stream_by_size -- <disc> <bytes>…
|
||||
use sylpheed_formats::media::{DirectorySource, DiscSource};
|
||||
use sylpheed_formats::{slb, PakArchive};
|
||||
|
||||
const DESC_MARK: u32 = 0x11;
|
||||
const DESC_REPEAT: usize = 0x800;
|
||||
const ID_MAX: u32 = 0x1_0000;
|
||||
|
||||
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
|
||||
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
|
||||
let mut out = Vec::new();
|
||||
if buf.len() < DESC_REPEAT + 8 {
|
||||
return out;
|
||||
}
|
||||
let end = buf.len() - (DESC_REPEAT + 4);
|
||||
let mut o = 0;
|
||||
while o <= end {
|
||||
let id = be(o);
|
||||
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
|
||||
out.push((o, id));
|
||||
}
|
||||
o += 4;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let disc = args.next().expect("usage: find_stream_by_size <disc> <bytes>…");
|
||||
let wanted: Vec<usize> = args.filter_map(|a| a.parse().ok()).collect();
|
||||
assert!(!wanted.is_empty(), "give at least one payload size");
|
||||
// A `to_xma_riffs` chunk is the payload plus a 60-byte RIFF wrapper.
|
||||
let want_riff: Vec<usize> = wanted.iter().map(|w| w + 60).collect();
|
||||
println!("looking for payloads {wanted:?} (riff sizes {want_riff:?})\n");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
|
||||
// --- 1. the continuous movie-voice stream, span by span
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let marker = "eng\\Movie\\VOICE_ADV.slb";
|
||||
let registry = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
tpak.read(e)
|
||||
.ok()
|
||||
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
|
||||
})
|
||||
.expect("registry");
|
||||
let ids = sylpheed_formats::movie_voice::registry_voice_ids(®istry);
|
||||
let name_of: std::collections::HashMap<u32, String> =
|
||||
ids.iter().map(|(n, &i)| (i, n.clone())).collect();
|
||||
let win_start: u64 = 421_739_888 & !3;
|
||||
let buf = src
|
||||
.read_segment_range("dat/sound", win_start, 116_300_000)
|
||||
.expect("window");
|
||||
let descs = all_descriptors(&buf);
|
||||
println!("voice stream: {} descriptors", descs.len());
|
||||
let mut hits = 0;
|
||||
for w in descs.windows(2) {
|
||||
let (a, b) = (w[0].0, w[1].0);
|
||||
if b <= a || b - a < 4096 {
|
||||
continue;
|
||||
}
|
||||
for (i, r) in slb::to_xma_riffs(&buf[a..b]).iter().enumerate() {
|
||||
if want_riff.contains(&r.len()) {
|
||||
let name = name_of
|
||||
.get(&w[1].1)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("id{}", w[1].1));
|
||||
println!(
|
||||
" ✅ cue {name} (id {}) stream {i}: payload {} B",
|
||||
w[1].1,
|
||||
r.len() - 60
|
||||
);
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(" {hits} hit(s) in the voice stream\n");
|
||||
|
||||
// --- 2. every sound.pak entry
|
||||
let stoc = src.read_file("dat/sound.pak").expect("sound.pak toc");
|
||||
let entries = PakArchive::parse_toc(&stoc).expect("toc");
|
||||
println!("sound.pak: {} entries", entries.len());
|
||||
let mut phits = 0;
|
||||
let mut scanned = 0usize;
|
||||
for e in &entries {
|
||||
// Only entries big enough to hold the target.
|
||||
let need = wanted.iter().copied().min().unwrap_or(0) as u32;
|
||||
if e.comp_size < need {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = src.read_segment_range("dat/sound", e.offset as u64, e.comp_size as usize)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
scanned += 1;
|
||||
for (i, r) in slb::to_xma_riffs(&bytes).iter().enumerate() {
|
||||
if want_riff.contains(&r.len()) {
|
||||
println!(
|
||||
" ✅ sound.pak entry hash {:08x} offset {} size {} — stream {i}: payload {} B",
|
||||
e.name_hash,
|
||||
e.offset,
|
||||
e.comp_size,
|
||||
r.len() - 60
|
||||
);
|
||||
phits += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(" scanned {scanned} entries large enough; {phits} hit(s)");
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
//! Which focus records have a VARYING alpha — disc-wide, not export-wide?
|
||||
//!
|
||||
//! `rest()` returns an element's last hold keyframe. For a constant-alpha
|
||||
//! element that is harmless. For one that pulses it returns the PEAK, which is
|
||||
//! the `ui-settle-time.md` pathology: the plate's `ptbtn00f` ramps 0→80→0 and
|
||||
//! `rest()` reports 80, its maximum.
|
||||
//!
|
||||
//! The port censused this over its own export (34 records, 2 varying) and
|
||||
//! concluded there is nothing to fix. That conclusion is only as wide as the
|
||||
//! export. This asks the same question of the whole disc.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let (mut n_rec, mut n_elem, mut varying) = (0usize, 0usize, 0usize);
|
||||
let (mut at_peak, mut mid_ramp) = (0usize, 0usize);
|
||||
let mut by_pak: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
let mut hits: Vec<String> = Vec::new();
|
||||
for p in &paks {
|
||||
let pn = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for (ei, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for (rn, &(o, s)) in &b.records {
|
||||
// A focus record is one whose name is another record's plus `f`.
|
||||
let Some(stem) = rn.strip_suffix("f.rat") else { continue };
|
||||
if !b.records.contains_key(&format!("{stem}.rat")) { continue }
|
||||
if o + s > by.len() { continue }
|
||||
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
|
||||
n_rec += 1;
|
||||
for el in &lb.elements {
|
||||
if el.keyframes.is_empty() { continue }
|
||||
n_elem += 1;
|
||||
let a: Vec<u32> = el.keyframes.iter().map(|k| k.fade >> 24).collect();
|
||||
let (lo, hi) = (*a.iter().min().unwrap(), *a.iter().max().unwrap());
|
||||
if lo == hi { continue }
|
||||
varying += 1;
|
||||
let rest = el.rest().map(|k| k.fade >> 24).unwrap_or(0);
|
||||
if rest == hi { at_peak += 1 } else { mid_ramp += 1 }
|
||||
*by_pak.entry(pn.clone()).or_default() += 1;
|
||||
hits.push(format!(
|
||||
"{pn} [{ei}] {rn}::{} alpha {lo}..{hi} rest()={rest}{}",
|
||||
el.name, if rest == hi { " 🔴 == PEAK" } else { "" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("focus records disc-wide : {n_rec}");
|
||||
println!(" their timed elements : {n_elem}");
|
||||
println!(" with a VARYING alpha : {varying}");
|
||||
println!(" of which rest() == the PEAK : {at_peak} <- burns bright forever");
|
||||
println!(" of which rest() is MID-RAMP : {mid_ramp} <- neither extreme; looks plausible");
|
||||
println!("\nby pak:");
|
||||
for (k, v) in &by_pak { println!(" {k:<34} {v}") }
|
||||
println!("\nevery varying one:");
|
||||
hits.sort(); hits.dedup();
|
||||
for h in &hits { println!(" {h}") }
|
||||
println!("\n({} distinct)", hits.len());
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Reconcile two ink counts for one screen that were never counting the same pixels.
|
||||
//!
|
||||
//! The port agent double-witnessed the pixel-cost claim in Godot — a renderer
|
||||
//! sharing no code with `compose` — and got `GP_TITLE` entry 12 at **59 530 px**
|
||||
//! ink above threshold 0 and **48 368** above 1. This crate reported **49 771**.
|
||||
//! Neither is wrong; the question is which convention each was using, and on a
|
||||
//! mostly-dark frame the answer moves thousands of pixels.
|
||||
//!
|
||||
//! So: count the same composite every way, and print the family. Whichever row
|
||||
//! the port's numbers land in is the convention, and then the two renderers can be
|
||||
//! compared on purpose rather than by coincidence.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example forced_backdrop_ink_thresholds
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use ui_layout::ComposeOptions;
|
||||
|
||||
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
|
||||
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
|
||||
idx.sort_by_key(|&i| {
|
||||
let el = &build.elements[i];
|
||||
(
|
||||
ui_layout::sprite_layer_key(build, bundle, el)
|
||||
.or_else(|| ui_layout::implied_layer_key(&el.name))
|
||||
.unwrap_or(u32::MAX),
|
||||
i,
|
||||
)
|
||||
});
|
||||
idx
|
||||
}
|
||||
|
||||
fn counts(rgba: &[u8], t: u8) -> (usize, usize) {
|
||||
let rgb = rgba
|
||||
.chunks_exact(4)
|
||||
.filter(|p| p[0] > t || p[1] > t || p[2] > t)
|
||||
.count();
|
||||
let alpha = rgba.chunks_exact(4).filter(|p| p[3] > t).count();
|
||||
(rgb, alpha)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
|
||||
for entry in [12usize, 15] {
|
||||
let by = ar.read(&ar.entries()[entry]).expect("entry");
|
||||
let b = ui_layout::parse_build(&by).expect("parse");
|
||||
for (label, opts) in [
|
||||
(
|
||||
"primitives on (what the cost run used)",
|
||||
ComposeOptions {
|
||||
include_primitives: true,
|
||||
backdrop: [0, 0, 0, 255],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"primitives+focus+animated",
|
||||
ComposeOptions {
|
||||
include_primitives: true,
|
||||
include_focus: true,
|
||||
include_animated: true,
|
||||
backdrop: [0, 0, 0, 255],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
let with = ui_layout::derived_paint_order(&b, &by);
|
||||
let without = order_without_rule(&b, &by);
|
||||
let a = ui_layout::compose_with_order(&b, &by, opts.clone(), None, Some(&with));
|
||||
let c = ui_layout::compose_with_order(&b, &by, opts, None, Some(&without));
|
||||
println!("\n== GP_TITLE entry {entry} — {label} ({}x{})", a.width, a.height);
|
||||
println!(" threshold | RGB>t with rule | A>t with rule | RGB>t WITHOUT | A>t WITHOUT");
|
||||
for t in [0u8, 1, 2, 4, 8, 16] {
|
||||
let (r1, a1) = counts(&a.rgba, t);
|
||||
let (r0, a0) = counts(&c.rgba, t);
|
||||
println!(" >{t:<8} | {r1:>16} | {a1:>14} | {r0:>13} | {a0:>11}");
|
||||
}
|
||||
let changed = a
|
||||
.rgba
|
||||
.chunks_exact(4)
|
||||
.zip(c.rgba.chunks_exact(4))
|
||||
.filter(|(x, y)| x != y)
|
||||
.count();
|
||||
println!(" exact-RGBA changed pixels between the two orders: {changed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
//! Of the forced instances the rule merely CONFIRMS, how many have a key READ
|
||||
//! FROM THE FILE, and how many an IMPLIED key that is itself a measurement?
|
||||
//!
|
||||
//! `forced_backdrop_necessity.rs` asked only whether an element had *a* key,
|
||||
//! collapsing `sprite_layer_key` (a `u16` read out of the `T8aD` header — decoded)
|
||||
//! with `implied_layer_key` (this crate's per-name table of positions **measured
|
||||
//! in the running game**). For counting whether the rule moves anything that is
|
||||
//! the right question. For describing what a confirmation is *made of*, it is not:
|
||||
//! "the file already settles it" and "another measurement already settles it" are
|
||||
//! different claims, and a reader who sees "own key" will take the first.
|
||||
//!
|
||||
//! Raised by the port agent 2026-08-30.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example forced_backdrop_key_source
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
|
||||
let (mut read, mut implied, mut none) = (0usize, 0usize, 0usize);
|
||||
println!("# archive entry element key_source key");
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
let name = pak.file_name().unwrap().to_string_lossy().to_string();
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else {
|
||||
continue;
|
||||
};
|
||||
for el in &b.elements {
|
||||
if !ui_layout::forced_backdrop(&b, el) {
|
||||
continue;
|
||||
}
|
||||
let (src, key) = match ui_layout::sprite_layer_key(&b, &by, el) {
|
||||
Some(k) => {
|
||||
read += 1;
|
||||
("read_T8aD", Some(k))
|
||||
}
|
||||
None => match ui_layout::implied_layer_key(&el.name) {
|
||||
Some(k) => {
|
||||
implied += 1;
|
||||
("implied_MEASURED", Some(k))
|
||||
}
|
||||
None => {
|
||||
none += 1;
|
||||
("none", None)
|
||||
}
|
||||
},
|
||||
};
|
||||
println!(
|
||||
" {name} {i} {} {src} {}",
|
||||
el.name,
|
||||
key.map(|k| format!("0x{k:08X}")).unwrap_or("-".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\n# forced instances by key source:");
|
||||
println!("# read from the T8aD header (decoded): {read}");
|
||||
println!("# implied — this crate's MEASURED name table: {implied}");
|
||||
println!("# none — only forced_backdrop can speak: {none}");
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
//! Which screens does `forced_backdrop` DECIDE, and which does it merely agree with?
|
||||
//!
|
||||
//! Every check this corpus has run on the rule measured its **stability** — that
|
||||
//! no verdict moved when something else changed. That is a different property
|
||||
//! from **necessity**: an element whose position is already fixed by a read or an
|
||||
//! implied key is confirmed by the rule, not decided by it.
|
||||
//!
|
||||
//! So: compute `derived_paint_order` with the rule, and again with the
|
||||
//! `forced_backdrop` fallback removed, and report every entry whose order moves.
|
||||
//! Where nothing moves, the rule is decorative on that screen; where it moves,
|
||||
//! the rule is the only thing holding the order up.
|
||||
//!
|
||||
//! Raised by the port agent 2026-08-30. Reach note in
|
||||
//! `docs/re/structures/ui-forced-backdrop.md`.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example forced_backdrop_necessity -- [pak...]
|
||||
//!
|
||||
//! 🔴 With no argument this used to default to `GP_TITLE` alone, so a bare run
|
||||
//! reported **6 instances, not 80** — a thirteenth of the census, printed in the
|
||||
//! same format and reading like the whole thing. The port agent hit it and nearly
|
||||
//! filed the discrepancy back at me. It now walks every `dat/*.pak` by default and
|
||||
//! says on stderr how many archives it opened, because "I ran your instrument" has
|
||||
//! to mean the same thing to both of us.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
|
||||
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
|
||||
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
|
||||
idx.sort_by_key(|&i| {
|
||||
let el = &build.elements[i];
|
||||
(
|
||||
ui_layout::sprite_layer_key(build, bundle, el)
|
||||
.or_else(|| ui_layout::implied_layer_key(&el.name))
|
||||
.unwrap_or(u32::MAX),
|
||||
i,
|
||||
)
|
||||
});
|
||||
idx
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let (mut total_decides, mut total_agrees) = (0usize, 0usize);
|
||||
let mut paks: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
|
||||
if paks.is_empty() {
|
||||
let mut all: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
|
||||
.collect();
|
||||
all.sort();
|
||||
paks = all;
|
||||
}
|
||||
eprintln!("# scanning {} archive(s)", paks.len());
|
||||
for path in &paks {
|
||||
let ar = PakArchive::open(path).expect("pak");
|
||||
println!("# {}", path.display());
|
||||
println!("# entry forced decides elements note");
|
||||
|
||||
let mut decides = Vec::new();
|
||||
let mut agrees = Vec::new();
|
||||
#[allow(unused)]
|
||||
let _ = (&decides, &agrees);
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else {
|
||||
continue;
|
||||
};
|
||||
let with = ui_layout::derived_paint_order(&b, &by);
|
||||
let without = order_without_rule(&b, &by);
|
||||
|
||||
// Which elements does the rule fire on, and of those, which have no key
|
||||
// of their own to fall back on?
|
||||
let mut forced = Vec::new();
|
||||
let mut keyless = Vec::new();
|
||||
for el in &b.elements {
|
||||
if !ui_layout::forced_backdrop(&b, el) {
|
||||
continue;
|
||||
}
|
||||
forced.push(el.name.clone());
|
||||
let own = ui_layout::sprite_layer_key(&b, &by, el)
|
||||
.or_else(|| ui_layout::implied_layer_key(&el.name));
|
||||
if own.is_none() {
|
||||
keyless.push(el.name.clone());
|
||||
}
|
||||
}
|
||||
if forced.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let moved = with != without;
|
||||
if moved {
|
||||
decides.push(i);
|
||||
} else {
|
||||
agrees.push(i);
|
||||
}
|
||||
println!(
|
||||
" {i:>5} {:>6} {:>7} {:>8} forced=[{}] keyless=[{}]",
|
||||
forced.len(),
|
||||
if moved { "YES" } else { "no" },
|
||||
b.elements.len(),
|
||||
forced.join(","),
|
||||
keyless.join(","),
|
||||
);
|
||||
}
|
||||
println!("# rule DECIDES the order on entries {decides:?}");
|
||||
println!("# rule merely AGREES on entries {agrees:?}\n");
|
||||
total_decides += decides.len();
|
||||
total_agrees += agrees.len();
|
||||
}
|
||||
println!("# TOTAL over {} archive(s): {total_decides} deciding entries, \
|
||||
{total_agrees} agreeing", paks.len());
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
//! What does `forced_backdrop` cost IN PIXELS on the screens it decides?
|
||||
//!
|
||||
//! `forced_backdrop_necessity.rs` answers "does the derived ORDER move", which is
|
||||
//! a property of the sort. The port agent then pointed out — correctly — that its
|
||||
//! re-run of that probe was **my code executed twice**, not a second witness, so
|
||||
//! the disc-wide 62 has one measurement behind it and only `GP_TITLE` has two.
|
||||
//!
|
||||
//! This does not fix that (it is still this crate), but it moves the question to a
|
||||
//! **different layer**: render each deciding build twice, once in the order
|
||||
//! `compose` derives and once with the `forced_backdrop` fallback removed, and
|
||||
//! count the pixels that differ. "The order moved" and "the picture moved" are not
|
||||
//! the same claim, and the second is the one anybody cares about — the tie-break
|
||||
//! work already found overlapping reorders that cost exactly zero pixels.
|
||||
//!
|
||||
//! Each entry carries its own CONTROL: the pixel count of the composite itself.
|
||||
//! If a build renders empty, its zero means the instrument saw nothing, not that
|
||||
//! the rule is free.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example forced_backdrop_pixel_cost -- [pak...]
|
||||
//!
|
||||
//! With no argument it walks **every `dat/*.pak`** — the necessity probe defaulted
|
||||
//! to `GP_TITLE`, which made a bare run report a thirteenth of the census and read
|
||||
//! like the whole thing.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use ui_layout::ComposeOptions;
|
||||
|
||||
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
|
||||
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
|
||||
idx.sort_by_key(|&i| {
|
||||
let el = &build.elements[i];
|
||||
(
|
||||
ui_layout::sprite_layer_key(build, bundle, el)
|
||||
.or_else(|| ui_layout::implied_layer_key(&el.name))
|
||||
.unwrap_or(u32::MAX),
|
||||
i,
|
||||
)
|
||||
});
|
||||
idx
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
|
||||
if paks.is_empty() {
|
||||
let mut all: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
|
||||
.collect();
|
||||
all.sort();
|
||||
paks = all;
|
||||
}
|
||||
eprintln!("# scanning {} archive(s)", paks.len());
|
||||
|
||||
let opts = ComposeOptions {
|
||||
include_primitives: true,
|
||||
backdrop: [0, 0, 0, 255],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
println!("# archive entry element changed_px total_px ink_px(control) pct");
|
||||
let (mut decided, mut zero_cost, mut blind) = (0usize, 0usize, 0usize);
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
let name = pak.file_name().unwrap().to_string_lossy().to_string();
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else {
|
||||
continue;
|
||||
};
|
||||
let with = ui_layout::derived_paint_order(&b, &by);
|
||||
let without = order_without_rule(&b, &by);
|
||||
if with == without {
|
||||
continue;
|
||||
}
|
||||
let forced: Vec<&str> = b
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|el| ui_layout::forced_backdrop(&b, el))
|
||||
.map(|el| el.name.as_str())
|
||||
.collect();
|
||||
|
||||
let a = ui_layout::compose_with_order(&b, &by, opts.clone(), None, Some(&with));
|
||||
let c = ui_layout::compose_with_order(&b, &by, opts.clone(), None, Some(&without));
|
||||
let n = a
|
||||
.rgba
|
||||
.chunks_exact(4)
|
||||
.zip(c.rgba.chunks_exact(4))
|
||||
.filter(|(x, y)| x != y)
|
||||
.count();
|
||||
// Control: does this build put any ink down at all, against the bare
|
||||
// backdrop? A build that renders to nothing cannot show a reorder.
|
||||
let ink = a
|
||||
.rgba
|
||||
.chunks_exact(4)
|
||||
.filter(|p| p[..3] != [0, 0, 0])
|
||||
.count();
|
||||
let total = a.rgba.len() / 4;
|
||||
|
||||
decided += 1;
|
||||
if ink == 0 {
|
||||
blind += 1;
|
||||
} else if n == 0 {
|
||||
zero_cost += 1;
|
||||
}
|
||||
println!(
|
||||
" {name} {i} {} {n} {total} {ink} {:.2}%",
|
||||
forced.join(","),
|
||||
100.0 * n as f64 / total as f64
|
||||
);
|
||||
}
|
||||
}
|
||||
println!("\n# builds whose ORDER the rule decides: {decided}");
|
||||
println!("# of those, costing ZERO pixels: {zero_cost}");
|
||||
println!("# of those, BLIND (build renders no ink, control fails): {blind}");
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
//! Is `GP_DIALOG` 2/3 the ONLY build on the disc with four buttons at 259/329/399/469?
|
||||
//!
|
||||
//! Both agents recorded the same reach on the DIFFICULTY identification: entries
|
||||
//! 2/3 are picked out by button count and geometry, not by a binding from
|
||||
//! `DLG_SELECT_DIFFICULTY` to a pak entry, so "another four-button dialog with the
|
||||
//! same rows would be indistinguishable". This tests whether such a rival exists.
|
||||
//!
|
||||
//! CONTROL: the scan must find GP_DIALOG 2 and 3 themselves. A rival-search that
|
||||
//! cannot find the incumbent proves nothing.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example four_button_row_rivals
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const WANT: [i32; 4] = [259, 329, 399, 469];
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<_> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
|
||||
.collect();
|
||||
paks.sort();
|
||||
let (mut incumbent, mut rivals) = (0, 0);
|
||||
for p in &paks {
|
||||
let Ok(ar) = PakArchive::open(p) else { continue };
|
||||
let pname = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let mut ys: Vec<i32> = b
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|el| el.name.contains("btn") && !el.name.contains('f'))
|
||||
.map(|el| el.rest().map(|k| k.y).unwrap_or(el.pivot_y as i32))
|
||||
.collect();
|
||||
ys.sort();
|
||||
ys.dedup();
|
||||
if ys.len() != 4 {
|
||||
continue;
|
||||
}
|
||||
let close = ys.iter().zip(WANT.iter()).all(|(a, b)| (a - b).abs() <= 6);
|
||||
if close {
|
||||
let is_inc = pname == "GP_DIALOG.pak" && (i == 2 || i == 3);
|
||||
if is_inc { incumbent += 1 } else { rivals += 1 }
|
||||
println!(" {}{pname:24} entry {i:4} rows {ys:?}",
|
||||
if is_inc { "INCUMBENT " } else { "RIVAL " });
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\ncontrol: found {incumbent} incumbent build(s) (want 2) — {}",
|
||||
if incumbent == 2 { "PASSED" } else { "FAILED" });
|
||||
println!("{rivals} rival build(s) elsewhere on the disc");
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
//! The main menu's sprites, by their ALPHA channel — is `ptframe1`/`ptframe2`'s
|
||||
//! "no fully-opaque pixel" a property of the artwork, and does any alpha value
|
||||
//! look like a scale the game expands (e.g. 0..128) rather than 0..255?
|
||||
//!
|
||||
//! The port measures both frames as rendering too DARK against the capture, with
|
||||
//! the shortfall correlating with the BACKGROUND. Two different causes predict
|
||||
//! that: a background-scaling blend selected in code, or an alpha that is too
|
||||
//! LOW in our decode. This example tests the second, which is on the disc.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example frame_alpha_census
|
||||
use sylpheed_formats::{pak::PakArchive, t8ad, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn census(name: &str, img: &t8ad::T8adImage) {
|
||||
let n = (img.width * img.height) as usize;
|
||||
let mut hist = [0usize; 256];
|
||||
for p in 0..n {
|
||||
hist[img.rgba[p * 4 + 3] as usize] += 1;
|
||||
}
|
||||
let zero = hist[0];
|
||||
let full = hist[255];
|
||||
let max = (0..256).rev().find(|&a| hist[a] > 0).unwrap_or(0);
|
||||
let nonzero = n - zero;
|
||||
// the top five alpha values that actually occur, by population
|
||||
let mut top: Vec<(usize, usize)> = (1..256).map(|a| (hist[a], a)).filter(|&(c, _)| c > 0).collect();
|
||||
top.sort_unstable_by(|a, b| b.0.cmp(&a.0));
|
||||
let top5: Vec<String> = top.iter().take(5).map(|&(c, a)| format!("{a}x{c}")).collect();
|
||||
println!(
|
||||
"{name:<16} {}x{:<4} px={n:<8} a=0:{:5.1}% a=255:{:5.1}% max={max:<3} \
|
||||
partial(1..254)/nonzero={:5.1}% top:[{}]",
|
||||
img.width, img.height,
|
||||
100.0 * zero as f64 / n as f64,
|
||||
100.0 * full as f64 / n as f64,
|
||||
if nonzero > 0 { 100.0 * (nonzero - full) as f64 / nonzero as f64 } else { 0.0 },
|
||||
top5.join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
let pak = argv.iter().find(|a| a.parse::<usize>().is_err())
|
||||
.cloned().unwrap_or_else(|| "GP_TITLE".to_string());
|
||||
let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak");
|
||||
// Builds default to the two the port ships and can be overridden, so the
|
||||
// same census serves the title (4) and the `PRESS (A)` plate (2).
|
||||
let args: Vec<usize> = argv.iter().filter_map(|a| a.parse().ok()).collect();
|
||||
let builds: Vec<usize> = if args.is_empty() { vec![5, 6] } else { args };
|
||||
for build in builds {
|
||||
let Ok(by) = ar.read(&ar.entries()[build]) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
println!("=== {pak} build {build} ===");
|
||||
let mut names: Vec<&String> = b.sprites.keys().collect();
|
||||
names.sort();
|
||||
for n in names {
|
||||
let (off, size) = b.sprites[n];
|
||||
let s = &by[off..(off + size).min(by.len())];
|
||||
match t8ad::parse(s) {
|
||||
Some(img) => census(n, &img),
|
||||
None => println!("{n:<16} (not a T8aD / failed to parse)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//! The keyframe record's two unexplained words (`+4`, `+8`) and the fade/tint —
|
||||
//! do any of them separate the four elements the port measures as rendering too
|
||||
//! dark (`ptframe1`/`2`, `ptframe3`/`4`) from the ones it measures as accurate?
|
||||
//!
|
||||
//! The T8aD header does not: no word and no bit of `+0x04`/`+0x08` puts the four
|
||||
//! frames on one side and `pteff10` (max alpha 130, wholly semi-transparent, and
|
||||
//! rendered nearly exact) on the other. The keyframe is the other place a
|
||||
//! per-element draw mode could live.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example frame_keyframe_unknowns
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
println!("{:<22} {:>5} {:>3} {:>10} {:>10} {:>8} {:>8} {:>8}",
|
||||
"element", "build", "kf", "unknown_4", "unknown_8", "fade", "tint", "rot");
|
||||
let mut frame_sets: Vec<(String, i32, i32, u32, u32, i32)> = Vec::new();
|
||||
let mut other_sets: Vec<(String, i32, i32, u32, u32, i32)> = Vec::new();
|
||||
for build in [5usize, 6] {
|
||||
let by = ar.read(&ar.entries()[build]).expect("entry");
|
||||
let b = ui_layout::parse_build(&by).expect("build");
|
||||
for e in &b.elements {
|
||||
for (i, k) in e.keyframes.iter().enumerate() {
|
||||
println!("{:<22} {build:>5} {i:>3} {:>10} {:>10} {:08X} {:08X} {:>8}",
|
||||
e.name, k.unknown_4, k.unknown_8, k.fade, k.tint, k.rotation_deg);
|
||||
let row = (e.name.clone(), k.unknown_4, k.unknown_8, k.fade, k.tint, k.rotation_deg);
|
||||
if e.name.contains("frame") { frame_sets.push(row) } else { other_sets.push(row) }
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\nframe keyframes: {} other keyframes: {}", frame_sets.len(), other_sets.len());
|
||||
for (label, get) in [
|
||||
("unknown_4", 0usize), ("unknown_8", 1), ("fade", 2), ("tint", 3), ("rotation", 4),
|
||||
] {
|
||||
let val = |r: &(String, i32, i32, u32, u32, i32)| -> i64 {
|
||||
match get { 0 => r.1 as i64, 1 => r.2 as i64, 2 => r.3 as i64, 3 => r.4 as i64, _ => r.5 as i64 }
|
||||
};
|
||||
let fv: std::collections::BTreeSet<i64> = frame_sets.iter().map(val).collect();
|
||||
let ov: std::collections::BTreeSet<i64> = other_sets.iter().map(val).collect();
|
||||
let only_frames: Vec<&i64> = fv.iter().filter(|v| !ov.contains(v)).collect();
|
||||
println!("{label:<10} frames take {:?} others take {} distinct values; frame-only values: {:?}",
|
||||
fv, ov.len(), only_frames);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
//! Which T8aD header word, if any, separates the FOUR elements the port measures
|
||||
//! as rendering too dark (`ptframe1`/`2` on the main menu, `ptframe3`/`4` on
|
||||
//! `EXTRAS`) from the elements on the same two screens it measures as accurate?
|
||||
//!
|
||||
//! The control that matters: `pteff10` has max alpha 130 and no fully-opaque
|
||||
//! pixel — the same "wholly semi-transparent" property the port proposed as the
|
||||
//! reason the frames are special — and it renders nearly exact. So the separator
|
||||
//! must put `pteff10` on the ACCURATE side.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example frame_vs_accurate_words
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
let mut rows: Vec<(String, usize, Vec<u32>)> = Vec::new();
|
||||
for build in [5usize, 6] {
|
||||
let by = ar.read(&ar.entries()[build]).expect("entry");
|
||||
let b = ui_layout::parse_build(&by).expect("build");
|
||||
let mut names: Vec<&String> = b.sprites.keys().collect();
|
||||
names.sort();
|
||||
for n in names {
|
||||
let (off, size) = b.sprites[n];
|
||||
let s = &by[off..(off + size).min(by.len())];
|
||||
if s.len() < 48 || &s[0..4] != b"T8aD" { continue }
|
||||
let ws: Vec<u32> = (0..12)
|
||||
.map(|k| u32::from_be_bytes([s[k*4], s[k*4+1], s[k*4+2], s[k*4+3]]))
|
||||
.collect();
|
||||
rows.push((n.clone(), build, ws));
|
||||
}
|
||||
}
|
||||
let is_frame = |n: &str| n.starts_with("ptframe");
|
||||
println!("{:<16} {:>5} +0x04 +0x08 +0x1C +0x2C", "sprite", "build");
|
||||
for (n, b, w) in &rows {
|
||||
println!("{n:<16} {b:>5} {:08X} {:08X} {:08X} {:08X}{}",
|
||||
w[1], w[2], w[7], w[11], if is_frame(n) { " <- TOO DARK" } else { "" });
|
||||
}
|
||||
println!("\nwords where every ptframe* agrees and NO other sprite takes that value:");
|
||||
let frames: Vec<&(String, usize, Vec<u32>)> = rows.iter().filter(|(n,_,_)| is_frame(n)).collect();
|
||||
let mut any = false;
|
||||
for k in 0..12 {
|
||||
let v = frames[0].2[k];
|
||||
if !frames.iter().all(|r| r.2[k] == v) { continue }
|
||||
if rows.iter().any(|(n,_,w)| !is_frame(n) && w[k] == v) { continue }
|
||||
println!(" word {k} (+0x{:02X}) = {v:08X}", k*4); any = true;
|
||||
}
|
||||
if !any { println!(" NONE — no header word separates the four frames from the rest"); }
|
||||
println!("\nper-bit check on +0x04 and +0x08 (a bit that is 1 on all frames, 0 on all others):");
|
||||
let mut anyb = false;
|
||||
for &k in &[1usize, 2] {
|
||||
for bit in 0..32 {
|
||||
let on = |v: u32| (v >> bit) & 1 == 1;
|
||||
if frames.iter().all(|r| on(r.2[k])) && rows.iter().all(|(n,_,w)| is_frame(n) || !on(w[k])) {
|
||||
println!(" +0x{:02X} bit {bit} (0x{:X})", k*4, 1u32 << bit); anyb = true;
|
||||
}
|
||||
if frames.iter().all(|r| !on(r.2[k])) && rows.iter().all(|(n,_,w)| is_frame(n) || on(w[k])) {
|
||||
println!(" +0x{:02X} bit {bit} (0x{:X}) INVERTED", k*4, 1u32 << bit); anyb = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !anyb { println!(" NONE"); }
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
//! Every button record in `GP_TITLE.pak`, per entry.
|
||||
//!
|
||||
//! Testing half of the count-match in boot-config-and-gamepart-registry.md:
|
||||
//! "four menu items load an external archive, EXTRAS stays inside GP_TITLE".
|
||||
//! If DIFFICULTY (NEW GAME's destination, EASY/NORMAL/HARD/BACK) is also inside
|
||||
//! GP_TITLE, then NEW GAME loads nothing external and that reading is wrong.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example gp_title_buttons
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let mut btns: Vec<String> = b
|
||||
.records
|
||||
.keys()
|
||||
.filter(|n| n.starts_with("ptbtn"))
|
||||
.cloned()
|
||||
.collect();
|
||||
btns.sort();
|
||||
if btns.is_empty() {
|
||||
continue;
|
||||
}
|
||||
println!("entry {i:2} {:2} button records {:?}", btns.len(), btns);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
fn main(){
|
||||
let root=PathBuf::from(std::env::var("SYLPHEED_DISC").unwrap());
|
||||
let ar=PakArchive::open(root.join("dat/GP_TITLE.pak")).unwrap();
|
||||
for (i,e) in ar.entries().iter().enumerate(){
|
||||
let Ok(by)=ar.read(e) else { println!("{i:2} <unreadable>"); continue };
|
||||
let names: Vec<String> = ui_layout::parse_build(&by)
|
||||
.map(|b| b.sprites.keys().take(2).cloned().collect()).unwrap_or_default();
|
||||
let rec: Vec<String> = ui_layout::parse_build(&by)
|
||||
.map(|b| b.records.keys().take(2).cloned().collect()).unwrap_or_default();
|
||||
println!("{i:2} {} B sprites {:?} records {:?}", by.len(), names, rec);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
//! Is `GP_TITLE.pak` really "8 screens shipped twice, EN/JP"?
|
||||
//!
|
||||
//! The Q2 headline says each screen appears twice. The entry dump raised a
|
||||
//! doubt: entry 11 shows `palogo_gamearts` and entry 14 shows `palogo_seta`,
|
||||
//! which are different studios, not a language pair. If the two halves of a
|
||||
//! "pair" declare different sprites, "shipped twice" is the wrong description of
|
||||
//! at least that pair.
|
||||
//!
|
||||
//! CONTROL: a pair known to be a real EN/JP pair must come out as matching. 2/3
|
||||
//! (the PRESS Ⓐ plate) is byte-identical in size and is the control.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example gp_title_pair_check
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn sprites(ar: &PakArchive, i: usize) -> BTreeSet<String> {
|
||||
let Ok(by) = ar.read(&ar.entries()[i]) else { return BTreeSet::new() };
|
||||
ui_layout::parse_build(&by)
|
||||
.map(|b| b.sprites.keys().cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
let pairs = [(0, 1, "loading plain"), (2, 3, "PRESS (A) plate [CONTROL]"),
|
||||
(4, 7, "title art"), (5, 8, "main menu"), (6, 9, "EXTRAS"),
|
||||
(10, 13, "publisher splash"), (11, 14, "developer splash"),
|
||||
(12, 15, "loading dressed")];
|
||||
for (a, b, what) in pairs {
|
||||
let (sa, sb) = (sprites(&ar, a), sprites(&ar, b));
|
||||
let only_a: Vec<_> = sa.difference(&sb).cloned().collect();
|
||||
let only_b: Vec<_> = sb.difference(&sa).cloned().collect();
|
||||
let shared = sa.intersection(&sb).count();
|
||||
let verdict = if only_a.is_empty() && only_b.is_empty() { "IDENTICAL SET" }
|
||||
else { "DIFFERS" };
|
||||
println!("\n{a:2}/{b:<2} {what:26} {shared:3} shared {verdict}");
|
||||
if !only_a.is_empty() { println!(" only in {a}: {only_a:?}"); }
|
||||
if !only_b.is_empty() { println!(" only in {b}: {only_b:?}"); }
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
//! Do `ui_layout`'s two IN-RANGE fallbacks ever fire? Counted, disc-wide.
|
||||
//!
|
||||
//! An in-range fallback supplies a value that is legitimate, so no output can
|
||||
//! distinguish it from the real thing and inspection cannot settle it. The only
|
||||
//! question that has an answer is *how often does it fire*.
|
||||
//!
|
||||
//! ui_layout.rs:1681 kf.time.unwrap_or(0) -- 0 is a real keyframe time
|
||||
//! (pose 0's time IS 0), so a fabricated one is invisible.
|
||||
//! ui_layout.rs:1010 pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0
|
||||
//! -- alpha 0 is legitimate, and it makes "no pose here"
|
||||
//! read as "fully transparent", biasing an occlusion test
|
||||
//! toward NOT occluded.
|
||||
//!
|
||||
//! (ui_layout.rs:973's `unwrap_or(0)` is NOT counted: it is guarded two lines
|
||||
//! later by `if tmax == 0 { return false; }`, so 0 is handled, not assumed.)
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example inrange_fallback_count
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
|
||||
paks.sort();
|
||||
let (mut kf, mut untimed, mut builds) = (0u64, 0u64, 0u64);
|
||||
let (mut queries, mut none_at) = (0u64, 0u64);
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ui_layout::is_build(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
builds += 1;
|
||||
// (a) :1681 -- how many poses carry no time?
|
||||
for el in &b.elements {
|
||||
for k in &el.keyframes {
|
||||
kf += 1;
|
||||
if k.time.is_none() { untimed += 1 }
|
||||
}
|
||||
}
|
||||
// (b) :1010 -- ask every element for a pose at every time that any
|
||||
// element declares, which is the set the occlusion test draws from.
|
||||
let mut times: Vec<u32> = b.elements.iter()
|
||||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)).collect();
|
||||
times.sort_unstable(); times.dedup();
|
||||
for el in &b.elements {
|
||||
for &t in × {
|
||||
queries += 1;
|
||||
if el.pose_at(t).is_none() { none_at += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
print!("."); std::io::stdout().flush().ok();
|
||||
}
|
||||
println!();
|
||||
println!("{builds} builds, {kf} keyframes");
|
||||
println!(":1681 untimed poses (the fallback would fabricate t=0): {untimed}");
|
||||
println!(":1010 pose_at queries {queries}, of which None (fallback reads a=0): {none_at}");
|
||||
// NEGATIVE CONTROL. Both counters above report 0, and a zero is the result
|
||||
// this corpus has learned to distrust most -- it reads clean rather than
|
||||
// suspicious. So prove the detector CAN see a hit: ask every element for a
|
||||
// pose at a time no build declares. If pose_at is total, `none_out` is 0 too
|
||||
// and the 0 above means nothing.
|
||||
let mut out_queries = 0u64;
|
||||
let mut none_out = 0u64;
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ui_layout::is_build(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for el in &b.elements {
|
||||
for &t in &[u32::MAX, 1_000_000u32] {
|
||||
out_queries += 1;
|
||||
if el.pose_at(t).is_none() { none_out += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("CONTROL pose_at at an undeclared time: {out_queries} queries, {none_out} None");
|
||||
println!(" (if this is 0 the detector is blind and the 0 above is meaningless)");
|
||||
println!("--- END ---");
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
//! Do `+4` / `+8` = 180 mean MIRROR?
|
||||
//!
|
||||
//! The disc-wide census shows `+4` and `+8` are dominated by 180 and ±90, while
|
||||
//! `+12` (the decoded screen-plane rotation) takes 157 distinct values including
|
||||
//! odd ones. That shape says flips rather than free rotation.
|
||||
//!
|
||||
//! Structural test, no renderer involved: if 180 means "mirror", then the same
|
||||
//! sprite should appear both with the field 0 and with it 180 **within one
|
||||
//! build** -- a mirrored pair. Free-rotation semantics predicts no such pairing.
|
||||
//!
|
||||
//! CONTROL: the same search run on `+12`, which is decoded as a real rotation
|
||||
//! and should NOT show a 0/180 pairing pattern of the same strength.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("<disc>/dat");
|
||||
let mut paks: Vec<_> = std::fs::read_dir(&dir).expect("dir")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false)).collect();
|
||||
paks.sort();
|
||||
|
||||
// field -> (pak, entry, sprite) -> set of values seen
|
||||
let mut seen: [BTreeMap<(String, usize, String), BTreeSet<i32>>; 3] =
|
||||
[BTreeMap::new(), BTreeMap::new(), BTreeMap::new()];
|
||||
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
let pn = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
for (i, e) in ar.entries().to_vec().iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&bytes) else { continue };
|
||||
let mut groups: Vec<(String, Vec<ui_layout::Keyframe>)> =
|
||||
b.elements.iter().map(|el| (el.name.clone(), el.keyframes.clone())).collect();
|
||||
for el in &b.elements {
|
||||
if let Some(&(off, size)) = b.records.get(&el.name) {
|
||||
if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) {
|
||||
for le in &lb.elements {
|
||||
groups.push((le.name.clone(), le.keyframes.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (nm, ks) in groups {
|
||||
for k in &ks {
|
||||
let key = (pn.clone(), i, nm.clone());
|
||||
seen[0].entry(key.clone()).or_default().insert(k.unknown_4);
|
||||
seen[1].entry(key.clone()).or_default().insert(k.unknown_8);
|
||||
seen[2].entry(key).or_default().insert(k.rotation_deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (idx, label) in [(0, "+4"), (1, "+8"), (2, "+12 (rotation, CONTROL)")] {
|
||||
let m = &seen[idx];
|
||||
let mut pair_0_180 = 0usize; // a sprite seen at BOTH 0 and 180 in one build
|
||||
let mut only_180 = 0usize;
|
||||
let mut multi = 0usize; // more than two distinct values
|
||||
for v in m.values() {
|
||||
if v.len() > 2 { multi += 1; }
|
||||
let has0 = v.contains(&0);
|
||||
let has180 = v.contains(&180) || v.contains(&-180);
|
||||
if has0 && has180 { pair_0_180 += 1; }
|
||||
else if has180 && !has0 { only_180 += 1; }
|
||||
}
|
||||
println!("{label}: {} sprite-instances", m.len());
|
||||
println!(" both 0 and ±180 in one build : {pair_0_180}");
|
||||
println!(" ±180 without any 0 : {only_180}");
|
||||
println!(" more than 2 distinct values : {multi}");
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap(); let bi:usize=a.next().unwrap().parse().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
let by=ar.read(&ar.entries()[bi]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
for t in a {
|
||||
let i:usize=t.parse().unwrap();
|
||||
let e=&b.elements[i];
|
||||
println!("[{i}] {} kind=0x{:x} parent={:?} pivot=({},{}) kfs={}",
|
||||
e.name, e.kind, e.parent, e.pivot_x, e.pivot_y, e.keyframes.len());
|
||||
for k in &e.keyframes {
|
||||
println!(" t={:<5} a={:<4} xy=({},{}) s={}/{} rot={} u4={} u8={} tint={:08x}",
|
||||
k.time.map(|v|v as i64).unwrap_or(-1), k.fade>>24, k.x,k.y,
|
||||
k.scale_x,k.scale_y,k.rotation_deg,k.unknown_4,k.unknown_8,k.tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
//! What are the keyframe block's `+4` and `+8`?
|
||||
//!
|
||||
//! `+12` is decoded as a screen-plane rotation in degrees. `+4` and `+8` sit
|
||||
//! immediately before it and are carried but unexplained; one standing 🟡
|
||||
//! reading is that the three together are rotations about three axes, "not tied
|
||||
//! to an observed rotation". This censuses them across every UI pak on the disc
|
||||
//! so the reading can be argued with rather than assumed.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("usage: <disc>/dat");
|
||||
let mut paks: Vec<_> = std::fs::read_dir(&dir)
|
||||
.expect("dir")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
|
||||
.collect();
|
||||
paks.sort();
|
||||
|
||||
let mut h4: BTreeMap<i32, usize> = BTreeMap::new();
|
||||
let mut h8: BTreeMap<i32, usize> = BTreeMap::new();
|
||||
let mut h12: BTreeMap<i32, usize> = BTreeMap::new();
|
||||
let mut both_nz: Vec<String> = Vec::new();
|
||||
let (mut kfs, mut builds) = (0usize, 0usize);
|
||||
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
let name = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
for (i, e) in ar.entries().to_vec().iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&bytes) else { continue };
|
||||
builds += 1;
|
||||
// parents and leaves alike
|
||||
let mut groups: Vec<(String, Vec<ui_layout::Keyframe>)> = b
|
||||
.elements.iter().map(|el| (el.name.clone(), el.keyframes.clone())).collect();
|
||||
for el in &b.elements {
|
||||
if let Some(&(off, size)) = b.records.get(&el.name) {
|
||||
if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) {
|
||||
for le in &lb.elements {
|
||||
groups.push((format!("{}->{}", el.name, le.name), le.keyframes.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (nm, ks) in groups {
|
||||
for k in &ks {
|
||||
kfs += 1;
|
||||
*h4.entry(k.unknown_4).or_default() += 1;
|
||||
*h8.entry(k.unknown_8).or_default() += 1;
|
||||
*h12.entry(k.rotation_deg).or_default() += 1;
|
||||
if k.unknown_4 != 0 || k.unknown_8 != 0 {
|
||||
both_nz.push(format!("{name} e{i} {nm} +4={} +8={} +12={}",
|
||||
k.unknown_4, k.unknown_8, k.rotation_deg));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{builds} builds, {kfs} keyframes (parents + leaves)\n");
|
||||
for (nm, h) in [("+4", &h4), ("+8", &h8), ("+12 (rotation)", &h12)] {
|
||||
let nz: usize = h.iter().filter(|(k, _)| **k != 0).map(|(_, v)| *v).sum();
|
||||
println!("{nm}: {} distinct values, {} non-zero keyframes ({:.4}%)",
|
||||
h.len(), nz, 100.0 * nz as f64 / kfs as f64);
|
||||
let mut top: Vec<_> = h.iter().filter(|(k, _)| **k != 0).collect();
|
||||
top.sort_by_key(|(_, v)| std::cmp::Reverse(**v));
|
||||
for (k, v) in top.iter().take(6) { println!(" {k:>8} x{v}"); }
|
||||
}
|
||||
println!("\nkeyframes with a non-zero +4 or +8: {}", both_nz.len());
|
||||
// Per-pak, so a reader can ask whether a pak they have a CAPTURE of is
|
||||
// among them -- which decides whether the field is testable at all.
|
||||
let mut per: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for l in &both_nz {
|
||||
let pak = l.split_whitespace().next().unwrap_or("?").to_string();
|
||||
*per.entry(pak).or_default() += 1;
|
||||
}
|
||||
println!(" by pak:");
|
||||
for (k, v) in &per { println!(" {k:<28} {v}"); }
|
||||
println!(" paks with NONE: (any UI pak not listed above)");
|
||||
if let Ok(f) = std::env::var("KF_SHOW") {
|
||||
println!("\n all lines for {f}:");
|
||||
for l in both_nz.iter().filter(|l| l.starts_with(&f)) { println!(" {l}"); }
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
//! Refutation check on `sylpheed-port`'s kind census: *"Every sprite decoration
|
||||
//! on both screens is `0x0` — `ptframe1`…`ptframe4` included — and every button
|
||||
//! is `0x3002`."*
|
||||
//!
|
||||
//! Their exporter decodes the field independently; this reads it from the other
|
||||
//! side. The check is deliberately WIDER than their claim in two ways, because a
|
||||
//! census that only looks where the claim looks cannot fail:
|
||||
//!
|
||||
//! * it covers every element, not only `.t32` sprites, so a decoration with an
|
||||
//! unexpected kind cannot hide behind the word "sprite";
|
||||
//! * it covers every build of `GP_TITLE`, not the two screens they checked, so
|
||||
//! the claim's *reach* gets tested and not just its instances.
|
||||
//!
|
||||
//! It also cross-checks `kind` against the focus/nav index at `+0x2C`, which is
|
||||
//! `-1` on anything that cannot take the cursor. That turns a census into a
|
||||
//! decode: if the two fields agree everywhere, the bit that separates them is
|
||||
//! identified rather than guessed. Run over EVERY UI pak on the disc, not just
|
||||
//! `GP_TITLE`, so the claim is disc-wide.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example kind_census_five_screens
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn suffix(n: &str) -> &str {
|
||||
match n.rfind('.') { Some(i) => &n[i..], None => "(none)" }
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
let n = ar.entries().len();
|
||||
// kind -> suffix -> count, and the exceptions we care about by name
|
||||
let mut table: BTreeMap<u32, BTreeMap<String, usize>> = BTreeMap::new();
|
||||
let mut t32_nonzero: Vec<(usize, String, u32)> = Vec::new();
|
||||
let mut btn_nonstd: Vec<(usize, String, u32)> = Vec::new();
|
||||
let mut builds = 0usize;
|
||||
for e in 0..n {
|
||||
let by = match ar.read(&ar.entries()[e]) { Ok(b) => b, Err(_) => continue };
|
||||
let b = match ui_layout::parse_build(&by) { Some(b) => b, None => continue };
|
||||
builds += 1;
|
||||
for el in &b.elements {
|
||||
*table.entry(el.kind).or_default().entry(suffix(&el.name).to_string()).or_default() += 1;
|
||||
if el.name.ends_with(".t32") && !el.name.contains("btn") && el.kind != 0 {
|
||||
t32_nonzero.push((e, el.name.clone(), el.kind));
|
||||
}
|
||||
if el.name.contains("btn") && el.kind != 0x3002 {
|
||||
btn_nonstd.push((e, el.name.clone(), el.kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("GP_TITLE: {builds} parseable builds of {n} entries\n");
|
||||
println!("{:<10} {}", "kind", "elements by file suffix");
|
||||
for (k, m) in &table {
|
||||
let s: Vec<String> = m.iter().map(|(sfx, c)| format!("{sfx}x{c}")).collect();
|
||||
println!("0x{k:<8X} {}", s.join(" "));
|
||||
}
|
||||
println!("\nNON-BUTTON .t32 elements with kind != 0: {}", t32_nonzero.len());
|
||||
for (e, nm, k) in t32_nonzero.iter().take(30) { println!(" entry {e:>2} {nm:<24} kind 0x{k:X}"); }
|
||||
println!("\n*btn* elements with kind != 0x3002: {}", btn_nonstd.len());
|
||||
for (e, nm, k) in btn_nonstd.iter().take(30) { println!(" entry {e:>2} {nm:<24} kind 0x{k:X}"); }
|
||||
|
||||
// Is `kind` a bitfield, and does bit 0x2 mean "focusable"? The focus/nav
|
||||
// index at +0x2C is -1 on everything that cannot take the cursor, so the two
|
||||
// fields cross-check each other. Printed rather than asserted: this is the
|
||||
// evidence for the reading, not the reading itself.
|
||||
println!("\nkind vs the focus index at +0x2C (-1 = not focusable), GP_TITLE:");
|
||||
// +0x2C is not in `Element`, so it is read straight out of the 60-byte
|
||||
// declaration entry: table at 0x20, 0x14 = count, entry stride 60.
|
||||
const AT: usize = 0x20;
|
||||
const STRIDE: usize = 60;
|
||||
let mut cross: BTreeMap<(u32, i32), usize> = BTreeMap::new();
|
||||
for e in 0..n {
|
||||
let by = match ar.read(&ar.entries()[e]) { Ok(b) => b, Err(_) => continue };
|
||||
if ui_layout::parse_build(&by).is_none() { continue }
|
||||
if by.len() < 0x18 { continue }
|
||||
let count = u32::from_be_bytes([by[0x14], by[0x15], by[0x16], by[0x17]]) as usize;
|
||||
for i in 0..count {
|
||||
let at = AT + i * STRIDE;
|
||||
if at + STRIDE > by.len() { break }
|
||||
let kind = u32::from_be_bytes([by[at+0x28], by[at+0x29], by[at+0x2A], by[at+0x2B]]);
|
||||
let foc = i32::from_be_bytes([by[at+0x2C], by[at+0x2D], by[at+0x2E], by[at+0x2F]]);
|
||||
*cross.entry((kind, if foc < 0 { -1 } else { 1 })).or_default() += 1;
|
||||
}
|
||||
}
|
||||
for ((k, f), c) in &cross {
|
||||
println!(" kind 0x{k:<6X} focus {:<10} {c:>4} elements",
|
||||
if *f < 0 { "= -1" } else { ">= 0" });
|
||||
}
|
||||
|
||||
// ── the same test, every UI pak on the disc ──────────────────────────────
|
||||
let mut all: BTreeMap<(u32, i32), usize> = BTreeMap::new();
|
||||
let mut paks = 0usize;
|
||||
let mut violations: Vec<String> = Vec::new();
|
||||
let mut dir: Vec<_> = std::fs::read_dir(root.join("dat")).expect("dat")
|
||||
.filter_map(|d| d.ok()).map(|d| d.path()).collect();
|
||||
dir.sort();
|
||||
for path in dir {
|
||||
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
if !name.ends_with(".pak") { continue }
|
||||
let ar = match PakArchive::open(&path) { Ok(a) => a, Err(_) => continue };
|
||||
let mut used = false;
|
||||
for i in 0..ar.entries().len() {
|
||||
let by = match ar.read(&ar.entries()[i]) { Ok(b) => b, Err(_) => continue };
|
||||
if ui_layout::parse_build(&by).is_none() { continue }
|
||||
if by.len() < 0x18 { continue }
|
||||
used = true;
|
||||
let count = u32::from_be_bytes([by[0x14], by[0x15], by[0x16], by[0x17]]) as usize;
|
||||
for e in 0..count {
|
||||
let at = AT + e * STRIDE;
|
||||
if at + STRIDE > by.len() { break }
|
||||
let kind = u32::from_be_bytes([by[at+0x28], by[at+0x29], by[at+0x2A], by[at+0x2B]]);
|
||||
let foc = i32::from_be_bytes([by[at+0x2C], by[at+0x2D], by[at+0x2E], by[at+0x2F]]);
|
||||
let f = if foc < 0 { -1 } else { 1 };
|
||||
*all.entry((kind, f)).or_default() += 1;
|
||||
if ((kind & 0x2) != 0) != (f > 0) {
|
||||
violations.push(format!("{name} entry {i} elem {e}: kind 0x{kind:X} focus {foc}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if used { paks += 1 }
|
||||
}
|
||||
let total: usize = all.values().sum();
|
||||
println!("\nDISC-WIDE — {paks} UI paks, {total} declaration entries");
|
||||
println!("{:<12} {:>12} {:>12}", "kind", "focus = -1", "focus >= 0");
|
||||
let kinds: std::collections::BTreeSet<u32> = all.keys().map(|(k, _)| *k).collect();
|
||||
for k in kinds {
|
||||
println!("0x{k:<10X} {:>12} {:>12}",
|
||||
all.get(&(k, -1)).copied().unwrap_or(0),
|
||||
all.get(&(k, 1)).copied().unwrap_or(0));
|
||||
}
|
||||
println!("\nHYPOTHESIS: bit 0x2 of kind == (focus index >= 0)");
|
||||
println!("violations: {} of {total}", violations.len());
|
||||
for v in violations.iter().take(20) { println!(" {v}"); }
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
//! How do a parent record's alpha ramp and its nested `.rat` leaf's compose?
|
||||
//!
|
||||
//! The port emits both but will not draw the leaf without knowing the rule --
|
||||
//! rightly, since drawing on a guess trades a visible 1.82 % error for an
|
||||
//! invisible wrong one. There is an oracle for this: the GPU draw capture
|
||||
//! records **vertex colours**, and on the title's `ptloop` draw they are
|
||||
//! `C3FFFFFF` and `B6FFFFFF` -- alpha **195** and **182**, not 255. So the game's
|
||||
//! composed alpha is observable, and a candidate rule either predicts those two
|
||||
//! numbers or does not.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example leaf_alpha_compose -- <GP_TITLE.pak>
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
|
||||
fn alpha_of(kf: &ui_layout::Keyframe) -> u32 {
|
||||
// The keyframe block's +0 is an ARGB fade colour; alpha is its high byte.
|
||||
kf.fade >> 24
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("usage: <pak>");
|
||||
let ar = pak::PakArchive::open(&path).expect("open");
|
||||
let e = &ar.entries()[4]; // GP_TITLE entry 4 = the English title
|
||||
let bytes = ar.read(e).expect("read");
|
||||
let build = ui_layout::parse_build(&bytes).expect("build");
|
||||
|
||||
for name in ["ptloop01.rat", "ptloop02.rat"] {
|
||||
println!("\n=== {name} ===");
|
||||
if let Some(el) = build.elements.iter().find(|x| x.name == name) {
|
||||
println!(" PARENT keyframes (t, alpha, scale, rot, x,y):");
|
||||
for k in &el.keyframes {
|
||||
println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} ({},{})",
|
||||
k.time.map(|t| t as i64).unwrap_or(-1), alpha_of(k), k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y);
|
||||
}
|
||||
}
|
||||
match build.records.get(name) {
|
||||
Some(&(off, size)) => {
|
||||
let leaf = &bytes[off..off + size];
|
||||
match ui_layout::parse_build(leaf) {
|
||||
Some(lb) => {
|
||||
for le in &lb.elements {
|
||||
println!(" LEAF element {:?}:", le.name);
|
||||
for k in &le.keyframes {
|
||||
println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} ({},{})",
|
||||
k.time.map(|t| t as i64).unwrap_or(-1), alpha_of(k), k.scale_x, k.scale_y,
|
||||
k.rotation_deg, k.x, k.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => println!(" leaf did not parse"),
|
||||
}
|
||||
}
|
||||
None => println!(" no leaf record"),
|
||||
}
|
||||
}
|
||||
println!("\nOBSERVED in the draw capture: quad A alpha 195 (0xC3), quad B alpha 182 (0xB6)");
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
//! Dump a record's parent keyframes and its nested `.rat` leaf's, for any entry.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example leaf_dump -- <pak> <entry> <name>
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
|
||||
fn a(k: &ui_layout::Keyframe) -> u32 { k.fade >> 24 }
|
||||
fn t(k: &ui_layout::Keyframe) -> i64 { k.time.map(|v| v as i64).unwrap_or(-1) }
|
||||
|
||||
fn show(tag: &str, els: &[ui_layout::Element]) {
|
||||
for e in els {
|
||||
println!(" {tag} {:?} pivot=({},{}) kind={:#x}", e.name, e.pivot_x, e.pivot_y, e.kind);
|
||||
for k in &e.keyframes {
|
||||
println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} pos=({},{}) tint={:#010x}",
|
||||
t(k), a(k), k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y, k.tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut it = std::env::args().skip(1);
|
||||
let path = it.next().expect("pak");
|
||||
let entry: usize = it.next().expect("entry").parse().expect("entry");
|
||||
let want = it.next().expect("name");
|
||||
let ar = pak::PakArchive::open(&path).expect("open");
|
||||
let bytes = ar.read(&ar.entries()[entry]).expect("read");
|
||||
let b = ui_layout::parse_build(&bytes).expect("build");
|
||||
println!("entry {entry}: {} elements, design {}x{}", b.elements.len(), b.design_w, b.design_h);
|
||||
let els: Vec<_> = b.elements.iter().filter(|e| e.name.contains(&want)).cloned().collect();
|
||||
show("PARENT", &els);
|
||||
for e in &els {
|
||||
match b.records.get(&e.name) {
|
||||
Some(&(off, size)) => {
|
||||
println!(" -- leaf of {:?}: {size} B at {off}", e.name);
|
||||
match ui_layout::parse_build(&bytes[off..off + size]) {
|
||||
Some(lb) => show(" LEAF", &lb.elements),
|
||||
None => println!(" leaf did not parse"),
|
||||
}
|
||||
}
|
||||
None => println!(" -- {:?} has NO leaf record", e.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let ar=pak::PakArchive::open(std::env::args().nth(1).unwrap()).unwrap();
|
||||
let by=ar.read(&ar.entries()[4]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
for n in ["ptloop01.rat","ptloop02.rat"] {
|
||||
let el=b.elements.iter().find(|e| e.name==n).unwrap();
|
||||
println!("{n}: kind={:#x} animated={} rec={:?}", el.kind, el.animated,
|
||||
b.records.get(n).map(|&(o,s)|(o,s)));
|
||||
if let Some(&(o,s))=b.records.get(n) {
|
||||
match ui_layout::parse_build(&by[o..o+s]) {
|
||||
Some(lb)=>for le in &lb.elements {
|
||||
println!(" leaf {:?} sprite={:?} in_sprites={}", le.name, le.sprite,
|
||||
le.sprite.as_ref().map(|x| b.sprites.contains_key(x)).unwrap_or(false));
|
||||
},
|
||||
None=>println!(" leaf parse FAILED"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
// Leave-one-out over the elements that touch a band, dumping raw RGBA each time.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap();
|
||||
let bi:usize=a.next().unwrap().parse().unwrap();
|
||||
let dir=a.next().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
let by=ar.read(&ar.entries()[bi]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
let o=ui_layout::ComposeOptions{ backdrop:[0,0,0,255], ..Default::default() };
|
||||
let n=b.elements.len();
|
||||
let base=ui_layout::compose(&b,&by,o.clone(),None);
|
||||
std::fs::write(format!("{dir}/base.raw"),&base.rgba).unwrap();
|
||||
println!("canvas {}x{} elements {n}", base.width, base.height);
|
||||
for i in 0..n {
|
||||
let e=&b.elements[i];
|
||||
let Some(kf)=e.rest() else { continue };
|
||||
// only bother with elements whose rest pose can touch the band
|
||||
let mut v=vec![true;n]; v[i]=false;
|
||||
let c=ui_layout::compose(&b,&by,o.clone(),Some(&v));
|
||||
std::fs::write(format!("{dir}/wo-{i}.raw"),&c.rgba).unwrap();
|
||||
println!("{i}\t{}\t{}\t({},{})\ta={}", e.name,
|
||||
e.sprite.clone().unwrap_or_default(), kf.x, kf.y, kf.fade>>24);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
//! Does the `+0x08` falsifier actually identify `+0x08`?
|
||||
//!
|
||||
//! `ui-record-loop-length.md` (mine) rests on: an animation cannot restart before
|
||||
//! its own last pose, so a wrong reading should produce violations, and none exist
|
||||
//! in 1 781 records. `sylpheed-port` re-ran it at the neighbouring offsets and
|
||||
//! reports the falsifier ACCEPTS `+0x04` too — meaning it does not discriminate,
|
||||
//! and the real evidence is the exactness statistic I called a formality.
|
||||
//!
|
||||
//! This checks that from my own reader before I correct the page.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example loop_length_offset_discriminates
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn be32(b: &[u8], o: usize) -> Option<u32> {
|
||||
(b.len() >= o + 4).then(|| u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let dat = root.join("dat");
|
||||
let mut paks: Vec<_> = std::fs::read_dir(&dat)
|
||||
.expect("dat")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
|
||||
.collect();
|
||||
paks.sort();
|
||||
// offset -> (records, violations where word < max_t, exact matches)
|
||||
let mut stat = [(0usize, 0usize, 0usize); 3];
|
||||
let offsets = [0x04usize, 0x08, 0x0c];
|
||||
for p in &paks {
|
||||
let Ok(ar) = PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for (_, (off, size)) in &b.records {
|
||||
let rec = &by[*off..(*off + *size).min(by.len())];
|
||||
if rec.len() < 0x10 || &rec[0..4] != b"RATC" {
|
||||
continue;
|
||||
}
|
||||
let Some(leaf) = ui_layout::parse_build(rec) else { continue };
|
||||
let max_t = leaf
|
||||
.elements
|
||||
.iter()
|
||||
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max();
|
||||
let Some(max_t) = max_t else { continue };
|
||||
// sylpheed-port's reconciliation: max_t == 0 makes "does the word
|
||||
// equal the largest keyframe time?" vacuous, and those records were
|
||||
// silently in my denominator. Filter them and the counts must meet.
|
||||
if std::env::var("MEANINGFUL_ONLY").is_ok() && max_t == 0 { continue }
|
||||
for (i, o) in offsets.iter().enumerate() {
|
||||
if let Some(w) = be32(rec, *o) {
|
||||
stat[i].0 += 1;
|
||||
if w < max_t { stat[i].1 += 1 }
|
||||
if w == max_t { stat[i].2 += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{:>8} {:>9} {:>12} {:>14}", "offset", "records", "violations", "exact == max_t");
|
||||
for (i, o) in offsets.iter().enumerate() {
|
||||
let (n, v, x) = stat[i];
|
||||
println!(" +0x{o:02X} {n:>9} {v:>12} ({:>5.1}%) {x:>8} ({:>5.1}%)",
|
||||
100.0 * v as f64 / n.max(1) as f64, 100.0 * x as f64 / n.max(1) as f64);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//! What elements sit under the port's hot residual tiles on the main menu?
|
||||
//!
|
||||
//! `sylpheed-port` mapped the menu's edge residual at 64 px tiles and handed over
|
||||
//! coordinates without names — the element inventory is this side's. Hot tiles
|
||||
//! cluster at x 384–704, y 64–256, hottest at (512,128).
|
||||
//!
|
||||
//! ⚠️ Their tiles are in the frame they compare in; this prints DESIGN space, and
|
||||
//! the two differ by the capture offset (capture_y ≈ 64.8 + 0.992·design_y). Both
|
||||
//! readings are printed so the mapping is not assumed.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example main_menu_element_extents
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
let by = ar.read(&ar.entries()[5]).expect("entry 5");
|
||||
let b = ui_layout::parse_build(&by).expect("build");
|
||||
println!("{:<26} {:>6} {:>6} {:>7} {:>7} {}", "element", "x", "y", "pivot_x", "pivot_y", "sprite");
|
||||
let mut rows: Vec<(i32, i32, String, String)> = b
|
||||
.elements
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let k = e.rest();
|
||||
(k.map(|k| k.x).unwrap_or(0), k.map(|k| k.y).unwrap_or(0),
|
||||
e.name.clone(), e.sprite.clone().unwrap_or_default())
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by_key(|r| (r.1, r.0));
|
||||
for (x, y, n, s) in &rows {
|
||||
// flag anything whose rest position lands in the hot band, read both ways
|
||||
let cap_y = 64.82 + 0.9919 * (*y as f64);
|
||||
let hot_design = (384..=704).contains(x) && (64..=256).contains(y);
|
||||
let hot_capture = (384..=704).contains(x) && (64.0..=256.0).contains(&cap_y);
|
||||
let mark = match (hot_design, hot_capture) {
|
||||
(true, true) => " <- HOT both readings",
|
||||
(true, false) => " <- hot in DESIGN space",
|
||||
(false, true) => " <- hot in CAPTURE space",
|
||||
_ => "",
|
||||
};
|
||||
println!("{n:<26} {x:>6} {y:>6} {:>7} {:>7} {s}{mark}",
|
||||
b.elements.iter().find(|e| &e.name == n).map(|e| e.pivot_x).unwrap_or(0),
|
||||
b.elements.iter().find(|e| &e.name == n).map(|e| e.pivot_y).unwrap_or(0));
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//! Recover a `sound.pak` TOC name from its hash by generating candidates.
|
||||
//!
|
||||
//! The hash is a Barrett-reduction over the uppercased path, so it cannot be
|
||||
//! inverted — but the naming is regular enough to enumerate. Tries the shapes
|
||||
//! this disc actually uses for sound entries.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example name_from_hash -- <hex-hash>…
|
||||
use sylpheed_formats::hash::name_hash;
|
||||
|
||||
fn main() {
|
||||
let wanted: Vec<u32> = std::env::args()
|
||||
.skip(1)
|
||||
.filter_map(|a| u32::from_str_radix(a.trim_start_matches("0x"), 16).ok())
|
||||
.collect();
|
||||
assert!(!wanted.is_empty(), "give hashes in hex");
|
||||
let langs = ["eng", "jpn", ""];
|
||||
let dirs = ["", "Movie", "etc", "Voice", "Sound", "BGM", "bgm", "se", "SE"];
|
||||
let mut tried = 0usize;
|
||||
let mut check = |name: String, tried: &mut usize| {
|
||||
*tried += 1;
|
||||
let h = name_hash(&name);
|
||||
if wanted.contains(&h) {
|
||||
println!(" ✅ {h:08x} {name}");
|
||||
}
|
||||
};
|
||||
for l in langs {
|
||||
for d in dirs {
|
||||
let pre = match (l.is_empty(), d.is_empty()) {
|
||||
(true, true) => String::new(),
|
||||
(true, false) => format!("{d}\\"),
|
||||
(false, true) => format!("{l}\\"),
|
||||
(false, false) => format!("{l}\\{d}\\"),
|
||||
};
|
||||
for stem in ["BGM", "bgm", "JNGL", "jngl", "SE", "Static", "VOICE"] {
|
||||
for n in 0..1200u32 {
|
||||
check(format!("{pre}{stem}_{n:03}.slb"), &mut tried);
|
||||
check(format!("{pre}{stem}{n:03}.slb"), &mut tried);
|
||||
}
|
||||
}
|
||||
for bare in ["Static.slb", "static.slb", "SE.slb", "BGM.slb"] {
|
||||
check(format!("{pre}{bare}"), &mut tried);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("tried {tried} candidate names");
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
//! Where does `screen --build N`'s ORDINAL diverge from the pak ENTRY index?
|
||||
//!
|
||||
//! `screen render --build N` takes an ordinal into the filtered build list, not
|
||||
//! a pak entry. On `GP_TITLE` `[10]` is entry 12, which is how I rendered two
|
||||
//! loading screens while believing they were the splashes — and every downstream
|
||||
//! number validated. This enumerates the divergence across the disc so any
|
||||
//! `--build N` in `docs/` can be checked instead of trusted.
|
||||
//!
|
||||
//! Two lists, because `screen list --all` swaps the predicate (`is_composable`
|
||||
//! for `is_build`) and therefore RENUMBERS: `--build 4` and `--build 4 --all`
|
||||
//! are not necessarily the same object.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example ordinal_entry_map
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// One decompression pass per entry, both predicates applied to it: reading the
|
||||
/// archive twice doubled the cost on `GP_READY_ROOM` (902 entries) for nothing.
|
||||
fn maps(ar: &PakArchive) -> (Vec<usize>, Vec<usize>) {
|
||||
let (mut d, mut a) = (Vec::new(), Vec::new());
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if ui_layout::is_build(&by) { d.push(i) }
|
||||
if ui_layout::is_composable(&by) { a.push(i) }
|
||||
}
|
||||
(d, a)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
|
||||
paks.sort();
|
||||
let (mut clean, mut div, mut allshift) = (0usize, 0usize, 0usize);
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
let name = pak.file_name().unwrap().to_string_lossy().to_string();
|
||||
let (d, a) = maps(&ar);
|
||||
if d.is_empty() && a.is_empty() { continue }
|
||||
let bad = d.iter().enumerate().find(|(o, &e)| *o != e).map(|(o, _)| o);
|
||||
// does `--all` renumber? compare the entry each ordinal resolves to
|
||||
let shift = (0..d.len().min(a.len())).find(|&o| d[o] != a[o]);
|
||||
let tag = match bad {
|
||||
None => { clean += 1; format!("{:4} builds ordinal == entry throughout", d.len()) }
|
||||
Some(o) => { div += 1;
|
||||
let t: Vec<String> = d.iter().enumerate().skip(o).take(5)
|
||||
.map(|(x, &y)| format!("[{x}]->{y}")).collect();
|
||||
format!("{:4} builds 🔴 diverges at ordinal {o}: {}", d.len(), t.join(" ")) }
|
||||
};
|
||||
let s = match shift {
|
||||
Some(o) => { allshift += 1;
|
||||
format!(" ⚠️ --all renumbers from [{o}]: entry {} -> {}", d[o], a[o]) }
|
||||
None if a.len() != d.len() => format!(" (--all appends {} more)", a.len() - d.len()),
|
||||
None => String::new(),
|
||||
};
|
||||
println!("{name:30} {tag}{s}");
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
println!("\n{clean} archives ordinal==entry, {div} diverge, {allshift} renumbered by --all");
|
||||
println!("--- END ---");
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//! Are `palogo_gamearts_eff` / `palogo_seta_eff` dwell-FALLBACK cases, or PLATEAU
|
||||
//! cases? The port agent lists them among `GP_TITLE`'s four visible fallback
|
||||
//! fires; this census listed only `palogo_sqex_eff` and `palogo_anima_eff`.
|
||||
//!
|
||||
//! It matters because the two are different defects. A plateau is a pose the
|
||||
//! element genuinely HOLDS, and `rest_plateau()` returning it is correct. Only the
|
||||
//! fallback is the unsound path.
|
||||
//! cargo run -p sylpheed-formats --example palogo_eff_check
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for el in &b.elements {
|
||||
if !el.name.starts_with("palogo") || !el.name.contains("eff") { continue }
|
||||
// A single-keyframe element has no gap to maximise, so neither path
|
||||
// applies and `rest()` trivially returns the only pose. Excluding it
|
||||
// here matches the census, which filters `len < 2`.
|
||||
if el.keyframes.len() < 2 { continue }
|
||||
let plateau = el.keyframes.windows(2).position(|w| {
|
||||
w[0].x == w[1].x && w[0].y == w[1].y && w[0].scale_x == w[1].scale_x
|
||||
&& w[0].scale_y == w[1].scale_y && w[0].fade == w[1].fade
|
||||
});
|
||||
let ks: Vec<String> = el.keyframes.iter().map(|k| format!(
|
||||
"{}:a{} {},{} {}%",
|
||||
k.time.map(|v| v.to_string()).unwrap_or("-".into()),
|
||||
(k.fade >> 24) & 0xff, k.x, k.y, k.scale_x)).collect();
|
||||
let r = el.rest();
|
||||
println!("e{i:<3} {:24} kf=[{}]", el.name, ks.join(" "));
|
||||
println!(" plateau at pair {:?} -> path: {} rest a={} t={:?}",
|
||||
plateau,
|
||||
if plateau.is_some() { "PLATEAU (sound: the pose is held)" } else { "DWELL FALLBACK (unsound)" },
|
||||
r.map(|k| (k.fade >> 24) & 0xff).unwrap_or(0),
|
||||
r.and_then(|k| k.time));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//! When an element has MORE THAN ONE plateau, does `rest_plateau()` pick the
|
||||
//! wrong one — and is that the 21.9 % residual?
|
||||
//!
|
||||
//! `rest_vs_settle` found that among elements holding a pose ACROSS the screen's
|
||||
//! settle instant, `pose_at(settle)` and `rest()` still disagree 21.9 % of the
|
||||
//! time. I hypothesised that `rest_plateau()` picks the **longest** run (it does —
|
||||
//! `len >= any_len`), which need not be the run covering the settle instant.
|
||||
//!
|
||||
//! ⚠️ **Control**: on elements with exactly ONE plateau that covers the settle
|
||||
//! instant, the two MUST agree. If they do not, the hypothesis is not the
|
||||
//! explanation and something else is wrong.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example plateau_choice
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
|
||||
paks.sort();
|
||||
let (mut one_cov, mut one_agree) = (0usize, 0usize); // control
|
||||
let (mut multi_cov, mut multi_agree, mut multi_wrongrun) = (0usize, 0usize, 0usize);
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let Some((lo, hi)) = b.settle_window() else { continue };
|
||||
if hi - lo < 10 { continue }
|
||||
let st = lo + (hi - lo) / 2;
|
||||
for el in &b.elements {
|
||||
let k = &el.keyframes;
|
||||
if k.len() < 2 { continue }
|
||||
let same = |a: &ui_layout::Keyframe, c: &ui_layout::Keyframe| {
|
||||
a.fade == c.fade && a.scale_x == c.scale_x && a.scale_y == c.scale_y
|
||||
&& a.tint == c.tint && a.x == c.x && a.y == c.y
|
||||
};
|
||||
// enumerate maximal runs of length >= 2, with their time spans
|
||||
let mut runs: Vec<(usize, usize)> = Vec::new();
|
||||
let mut i = 0usize;
|
||||
while i < k.len() {
|
||||
let mut j = i;
|
||||
while j + 1 < k.len() && same(&k[j], &k[j + 1]) { j += 1 }
|
||||
if j - i + 1 >= 2 { runs.push((i, j)) }
|
||||
i = j + 1;
|
||||
}
|
||||
if runs.is_empty() { continue }
|
||||
let covers = |&(a, c): &(usize, usize)| match (k[a].time, k[c].time) {
|
||||
(Some(t0), Some(t1)) => t0 <= st && st <= t1,
|
||||
_ => false,
|
||||
};
|
||||
let covering: Vec<_> = runs.iter().filter(|r| covers(r)).collect();
|
||||
if covering.is_empty() { continue }
|
||||
let (Some(r), Some(s)) = (el.rest(), el.pose_at(st)) else { continue };
|
||||
let agree = r.fade == s.fade && r.x == s.x && r.y == s.y
|
||||
&& r.scale_x == s.scale_x && r.scale_y == s.scale_y;
|
||||
if runs.len() == 1 {
|
||||
one_cov += 1;
|
||||
if agree { one_agree += 1 }
|
||||
} else {
|
||||
multi_cov += 1;
|
||||
if agree { multi_agree += 1 }
|
||||
else {
|
||||
// did rest() land on a run that does NOT cover settle?
|
||||
let on_covering = covering.iter().any(|&&(a, c)| {
|
||||
(a..=c).any(|idx| {
|
||||
let kk = &k[idx];
|
||||
kk.fade == r.fade && kk.x == r.x && kk.y == r.y
|
||||
&& kk.scale_x == r.scale_x && kk.scale_y == r.scale_y
|
||||
})
|
||||
});
|
||||
if !on_covering { multi_wrongrun += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("CONTROL — exactly ONE plateau, and it covers the settle instant:");
|
||||
println!(" {one_cov} elements, rest() and pose_at(settle) agree on {one_agree} ({:.1} %)",
|
||||
100.0 * one_agree as f64 / one_cov.max(1) as f64);
|
||||
println!("\nTEST — MORE THAN ONE plateau, at least one covering the settle instant:");
|
||||
println!(" {multi_cov} elements, agree on {multi_agree} ({:.1} %)",
|
||||
100.0 * multi_agree as f64 / multi_cov.max(1) as f64);
|
||||
println!(" of the {} disagreements, rest() landed on a run that does NOT cover",
|
||||
multi_cov - multi_agree);
|
||||
println!(" the settle instant: {multi_wrongrun}");
|
||||
println!("\n--- END (if this line is missing, the run did not finish) ---");
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
//! Does a primitive's alpha AT t=0 predict the layer it paints on?
|
||||
//!
|
||||
//! `implied_layer_key` is a measured per-name table. The four entries in it, read
|
||||
//! against their own keyframes, suggest a rule derived from the file instead:
|
||||
//!
|
||||
//! * `palogo_eff0.prm` measured FIRST (0x0000) -- alpha at t=0 = ?
|
||||
//! * `pfbase.tbm` measured FIRST (0x0000) -- alpha at t=0 = ?
|
||||
//! * `pteff02.prm` measured MIDDLE (0x8030) -- alpha at t=0 = ?
|
||||
//! * `pteff00.prm` measured LAST -- alpha at t=0 = ?
|
||||
//!
|
||||
//! ⚠️ The rule was invented AFTER seeing three of those answers, so it is fitted
|
||||
//! on them and only `pfbase.tbm` is out of sample. This prints all four plus a
|
||||
//! disc-wide census, so the fit and its reach are visible together.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
// name -> (count, set of t=0 alphas, set of "starts at max" flags)
|
||||
let mut byname: BTreeMap<String, (usize, BTreeMap<u32, usize>)> = BTreeMap::new();
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for el in &b.elements {
|
||||
// primitives and the keyless: anything with no layer key
|
||||
if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue }
|
||||
let Some(k0) = el.keyframes.first() else { continue };
|
||||
let a0 = k0.fade >> 24;
|
||||
let ent = byname.entry(el.name.clone()).or_default();
|
||||
ent.0 += 1;
|
||||
*ent.1.entry(a0).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{:>28} {:>7} alpha at t=0 (count)", "keyless element", "n");
|
||||
let known = ["palogo_eff0.prm", "pfbase.tbm", "pteff02.prm", "pteff00.prm"];
|
||||
for (n, (c, a)) in &byname {
|
||||
let tag = if known.contains(&n.as_str()) { " <- IN THE MEASURED TABLE" } else { "" };
|
||||
if *c < 4 && tag.is_empty() { continue }
|
||||
let al: Vec<String> = a.iter().map(|(k, v)| format!("{k}x{v}")).collect();
|
||||
println!(" {n:>26} {c:>7} {}{tag}", al.join(" "));
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
//! What COLOUR is a primitive, and does any of them only make sense additively?
|
||||
//!
|
||||
//! `ui-prm-primitives.md` leaves blend mode open, and `forced_backdrop` assumes
|
||||
//! straight alpha-over. A quad whose ARGB would tint the whole screen a colour no
|
||||
//! screen shows is evidence against alpha-over for that quad.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
fn main(){
|
||||
let root=std::env::var("SYLPHEED_DISC").unwrap_or_else(|_|"/disc".into());
|
||||
let mut paks:Vec<_>=std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e|e.path())
|
||||
.filter(|p|p.extension().and_then(|s|s.to_str())==Some("pak")).collect();
|
||||
paks.sort();
|
||||
let mut m:BTreeMap<String,BTreeMap<String,usize>>=BTreeMap::new();
|
||||
for p in &paks {
|
||||
let Ok(ar)=pak::PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by)=ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b)=ui_layout::parse_build(&by) else { continue };
|
||||
for el in &b.elements {
|
||||
if el.sprite.is_some() { continue }
|
||||
for k in &el.keyframes {
|
||||
*m.entry(el.name.clone()).or_default()
|
||||
.entry(format!("{:08x}", k.fade)).or_default()+=1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{:>26} fade ARGB values (count)", "keyless element");
|
||||
for (n,v) in &m {
|
||||
let tot:usize=v.values().sum();
|
||||
if tot<4 { continue }
|
||||
let s:Vec<String>=v.iter().map(|(k,c)|format!("{k}x{c}")).collect();
|
||||
println!(" {n:>24} {}", s.join(" "));
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
//! Which keyless primitives have their paint position FORCED by occlusion?
|
||||
//!
|
||||
//! An opaque full-screen quad must sort below every element visible at any
|
||||
//! instant it is opaque. Where that set is *every* other element, its position is
|
||||
//! forced to first — derived from the file, not analogised from a neighbour.
|
||||
//!
|
||||
//! Controls, both measured in the running game and both reproduced here:
|
||||
//! * `palogo_eff0.prm` is measured painting FIRST — and comes out forced first.
|
||||
//! * `pteff00.prm` is measured painting LAST — and is forced below only a
|
||||
//! handful, so the constraint permits it on top.
|
||||
//!
|
||||
//! ⚠️ Assumes straight alpha-over blending. Blend mode is ❔ in
|
||||
//! `ui-prm-primitives.md`; an additive quad at alpha 255 would not occlude.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let (mut forced, mut partial, mut free) = (0usize, 0usize, 0usize);
|
||||
let mut names: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let tmax = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max().unwrap_or(0);
|
||||
if tmax == 0 { continue }
|
||||
for el in &b.elements {
|
||||
if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue }
|
||||
// full-screen only: a quad that does not cover cannot occlude
|
||||
if el.pivot_x * 2 < 1280 || el.pivot_y * 2 < 720 { continue }
|
||||
let op: Vec<u32> = (0..=tmax)
|
||||
.filter(|&t| el.pose_at(t).map(|k| k.fade >> 24) == Some(255)).collect();
|
||||
if op.is_empty() { continue }
|
||||
let others: Vec<&ui_layout::Element> =
|
||||
b.elements.iter().filter(|o| o.index != el.index).collect();
|
||||
if others.is_empty() { continue }
|
||||
let below = others.iter().filter(|o|
|
||||
op.iter().any(|&t| o.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0)).count();
|
||||
let ent = names.entry(el.name.clone()).or_default();
|
||||
ent.1 += 1;
|
||||
if below == others.len() { forced += 1; ent.0 += 1 }
|
||||
else if below > 0 { partial += 1 } else { free += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("keyless FULL-SCREEN primitives with an opaque interval:");
|
||||
println!(" position FORCED FIRST (below every other element) : {forced}");
|
||||
println!(" forced below SOME but not all : {partial}");
|
||||
println!(" occludes nothing : {free}");
|
||||
println!("\nby name — instances forced first / total:");
|
||||
for (n, (f, t)) in &names { println!(" {n:>24} {f:>4} / {t}"); }
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
//! An OPAQUE full-screen primitive cannot paint on top of elements that are
|
||||
//! visible at the same time — the screen would be blank.
|
||||
//!
|
||||
//! That is a constraint read off the file, not a preference. For each keyless
|
||||
//! primitive this computes the interval over which it is opaque, and the interval
|
||||
//! over which any OTHER element is visible, and reports the overlap.
|
||||
//!
|
||||
//! The falsifier: `pteff00.prm` is MEASURED painting last on the title and the
|
||||
//! main menu. If any of its instances is opaque while content is up, the
|
||||
//! constraint is wrong and this whole line is dead.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
/// Interval(s) where alpha >= `thr`, sampled at every half unit.
|
||||
fn opaque_span(el: &ui_layout::Element, thr: u32, tmax: u32) -> Vec<(f64, f64)> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<f64> = None;
|
||||
let mut t = 0.0;
|
||||
while t <= tmax as f64 {
|
||||
let a = el.pose_at(t as u32).map(|k| k.fade >> 24).unwrap_or(0);
|
||||
if a >= thr {
|
||||
if cur.is_none() { cur = Some(t) }
|
||||
} else if let Some(s) = cur.take() {
|
||||
out.push((s, t));
|
||||
}
|
||||
t += 0.5;
|
||||
}
|
||||
if let Some(s) = cur { out.push((s, tmax as f64)) }
|
||||
out
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let want = ["pteff00.prm", "pgloading_eff00.prm", "palogo_eff0.prm",
|
||||
"pfbase.tbm", "pteff02.prm", "pzeff00.prm", "pceff00.prm", "pdeff00.prm"];
|
||||
println!("{:>22} {:>5} {:>16} {:>18} overlap", "primitive", "entry", "opaque while", "content visible");
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for (ei, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let tmax = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max().unwrap_or(0);
|
||||
if tmax == 0 { continue }
|
||||
for el in &b.elements {
|
||||
if !want.contains(&el.name.as_str()) { continue }
|
||||
if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue }
|
||||
let op = opaque_span(el, 255, tmax);
|
||||
if op.is_empty() { continue }
|
||||
// when is any OTHER element visible?
|
||||
let mut cmin = f64::MAX; let mut cmax = f64::MIN;
|
||||
for o in &b.elements {
|
||||
if o.index == el.index { continue }
|
||||
for (s, t) in opaque_span(o, 1, tmax) { cmin = cmin.min(s); cmax = cmax.max(t) }
|
||||
}
|
||||
if cmin > cmax { continue }
|
||||
// overlap of the primitive's opaque span with the content span
|
||||
let ov: f64 = op.iter()
|
||||
.map(|&(s, t)| (t.min(cmax) - s.max(cmin)).max(0.0)).sum();
|
||||
let opd: String = op.iter().map(|&(s,t)| format!("{s:.0}-{t:.0}")).collect::<Vec<_>>().join(",");
|
||||
let flag = if ov > 2.0 { " 🔴 CANNOT BE ON TOP" } else { "" };
|
||||
println!("{:>22} {:>5} {:>16} {:>18} {ov:6.1}{flag}",
|
||||
el.name, format!("{}:{}", p.file_name().unwrap().to_string_lossy()
|
||||
.trim_end_matches(".pak").trim_start_matches("GP_"), ei),
|
||||
opd, format!("{cmin:.0}-{cmax:.0}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
//! Does `forced_backdrop`'s verdict depend on how the screen's timeline ENDS?
|
||||
//!
|
||||
//! The rule quantifies over "every instant the primitive is opaque" and "every
|
||||
//! element visible then", so both halves depend on where the timeline stops and
|
||||
//! on what an element does after its own last keyframe. The port asked, and it is
|
||||
//! the right question: a verdict that flips with the convention is not a decode.
|
||||
//!
|
||||
//! Four conventions, all applied to the same disc:
|
||||
//! A span = max keyframe time over all elements; elements HOLD their last pose
|
||||
//! (what `forced_backdrop` does, and what the port implements)
|
||||
//! B span = the primitive's OWN last keyframe time; elements hold
|
||||
//! C span = the bundle header `+0x08` (the declared length); elements hold
|
||||
//! D span = max keyframe time; an element is GONE after its own last keyframe
|
||||
//!
|
||||
//! D is the one worth the most: it is the assumption the port flagged as "doing
|
||||
//! real work", and it strictly shrinks the visible set, so it can only turn
|
||||
//! `forced` into `not forced`.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
fn last_t(el: &ui_layout::Element) -> u32 {
|
||||
el.keyframes.iter().filter_map(|k| k.time).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn forced(b: &ui_layout::UiBuild, el: &ui_layout::Element, tmax: u32, hold: bool) -> Option<bool> {
|
||||
if el.sprite.is_some() { return None }
|
||||
if (el.pivot_x * 2) < b.design_w as u32 || (el.pivot_y * 2) < b.design_h as u32 { return None }
|
||||
if tmax == 0 { return None }
|
||||
let alpha = |e: &ui_layout::Element, t: u32| -> u32 {
|
||||
if !hold && t > last_t(e) { return 0 }
|
||||
e.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0)
|
||||
};
|
||||
let op: Vec<u32> = (0..=tmax).filter(|&t| alpha(el, t) == 255).collect();
|
||||
if op.is_empty() { return None }
|
||||
let others: Vec<&ui_layout::Element> = b.elements.iter().filter(|o| o.index != el.index).collect();
|
||||
if others.is_empty() { return None }
|
||||
let below = others.iter().filter(|o| op.iter().any(|&t| alpha(o, t) > 0)).count();
|
||||
Some(below == others.len())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let (mut n, mut a_true) = (0usize, 0usize);
|
||||
let mut flips = [0usize; 3];
|
||||
let mut examples: Vec<String> = Vec::new();
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for (ei, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let tall = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max().unwrap_or(0);
|
||||
let hdr = if by.len() >= 12 { u32::from_be_bytes(by[8..12].try_into().unwrap()) } else { 0 };
|
||||
for el in &b.elements {
|
||||
let Some(va) = forced(&b, el, tall, true) else { continue };
|
||||
n += 1; if va { a_true += 1 }
|
||||
for (k, vb) in [forced(&b, el, last_t(el), true),
|
||||
forced(&b, el, hdr, true),
|
||||
forced(&b, el, tall, false)].into_iter().enumerate() {
|
||||
if vb != Some(va) {
|
||||
flips[k] += 1;
|
||||
if k == 2 && examples.len() < 6 {
|
||||
examples.push(format!("{}:{} {} A={va} D={vb:?}",
|
||||
p.file_name().unwrap().to_string_lossy(), ei, el.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("keyless full-screen primitives with an opaque interval: {n}");
|
||||
println!(" convention A (span = all elements' max, hold) -> forced first: {a_true}\n");
|
||||
println!(" verdicts that CHANGE under:");
|
||||
println!(" B span = the primitive's own last keyframe : {}", flips[0]);
|
||||
println!(" C span = the header's declared length +0x08 : {}", flips[1]);
|
||||
println!(" D elements GONE after their last keyframe : {}", flips[2]);
|
||||
for e in &examples { println!(" {e}") }
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
//! The FULL extent of the two title sweep leaves, across their whole cycle.
|
||||
//!
|
||||
//! `ptloop_leaf_sweep_at.rs` samples t=340..540 — a window chosen to compare two
|
||||
//! competing fits — so it never showed how far the leaves travel. That gap let a
|
||||
//! claim stand that `ptloop01/02` "do not free-run", measured over the PARENT's
|
||||
//! 200x90 rect, which is a pivot anchor the leaf spends almost no time inside.
|
||||
//! `sylpheed-port` reports x tracks of -639..1521 and -839..1721 from their
|
||||
//! export; this checks that against the disc.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example ptloop_leaf_extent
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
for entry in [4usize, 5, 7] {
|
||||
let Ok(by) = ar.read(&ar.entries()[entry]) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
println!("\n######## GP_TITLE entry {entry} ########");
|
||||
for parent in ["ptloop01", "ptloop02"] {
|
||||
let Some(el) = b.elements.iter().find(|e| e.name.starts_with(parent)) else {
|
||||
println!(" {parent}: not present in this build"); continue };
|
||||
let Some(&(off, size)) = b.records.get(&el.name) else {
|
||||
println!(" {}: no nested record", el.name); continue };
|
||||
let span = u32::from_be_bytes(by[off + 8..off + 12].try_into().unwrap());
|
||||
let Some(lb) = ui_layout::parse_build(&by[off..off + size]) else {
|
||||
println!(" {}: leaf will not parse", el.name); continue };
|
||||
println!(" {} parent rest ({},{}) nested cycle span {span}",
|
||||
el.name, el.keyframes.last().map(|k| k.x).unwrap_or(0),
|
||||
el.keyframes.last().map(|k| k.y).unwrap_or(0));
|
||||
for le in &lb.elements {
|
||||
let (mut lo, mut hi) = (i64::MAX, i64::MIN);
|
||||
let (mut sxs, mut sys) = (Vec::new(), Vec::new());
|
||||
for t in 0..=span {
|
||||
if let Some(k) = le.pose_at(t) {
|
||||
lo = lo.min(k.x as i64); hi = hi.max(k.x as i64);
|
||||
if !sxs.contains(&k.scale_x) { sxs.push(k.scale_x) }
|
||||
if !sys.contains(&k.scale_y) { sys.push(k.scale_y) }
|
||||
}
|
||||
}
|
||||
// A CYCLE LENGTH IS NOT A MOTION DURATION. Find the last t at
|
||||
// which x still changes: sylpheed-port reports the final segment
|
||||
// HOLDS, which would make px/unit larger than cycle-based maths.
|
||||
let mut last_move = 0u32;
|
||||
let mut prev = None;
|
||||
for t in 0..=span {
|
||||
if let Some(k) = le.pose_at(t) {
|
||||
if prev.map_or(false, |p| p != k.x) { last_move = t }
|
||||
prev = Some(k.x);
|
||||
}
|
||||
}
|
||||
let w = (le.pivot_x * 2) as i64;
|
||||
println!(" leaf {:<12} pivot {}x{} quad w={w} x track {lo} .. {hi} \
|
||||
(centre {} .. {}) scale_x {:?} scale_y {:?}",
|
||||
le.name, le.pivot_x, le.pivot_y,
|
||||
lo + le.pivot_x as i64, hi + le.pivot_x as i64, sxs, sys);
|
||||
println!(" motion ends at t={last_move} of a {span}-unit cycle -> {:.3} px/unit over the MOVING span (vs {:.3} over the cycle)",
|
||||
(hi - lo) as f64 / last_move.max(1) as f64,
|
||||
(hi - lo) as f64 / span as f64);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("--- END ---");
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
//! The sweep leaves' RAW keyframes, so a segment rate can be checked not assumed.
|
||||
//!
|
||||
//! `sylpheed-port` reports `pteff03` as +4.0000 px/unit over t 0..150 and +4.0000
|
||||
//! again over 150..540 -- perfectly linear -- against `pteff03a` at -4.0667 then
|
||||
//! -4.0625. That asymmetry is what makes their "inversion" observation sharp: my
|
||||
//! linearity gate fails on the leaf whose source is exactly straight. It is their
|
||||
//! number from their export, so it is worth deriving independently.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example ptloop_leaf_keyframes
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
let by = ar.read(&ar.entries()[4]).expect("entry 4");
|
||||
let b = ui_layout::parse_build(&by).expect("parse");
|
||||
for parent in ["ptloop01", "ptloop02"] {
|
||||
let Some(el) = b.elements.iter().find(|e| e.name.starts_with(parent)) else { continue };
|
||||
let Some(&(off, size)) = b.records.get(&el.name) else { continue };
|
||||
let Some(lb) = ui_layout::parse_build(&by[off..off + size]) else { continue };
|
||||
for le in &lb.elements {
|
||||
println!("\n== {} -> leaf {}", el.name, le.name);
|
||||
let ks: Vec<_> = le.keyframes.iter().collect();
|
||||
for w in ks.windows(2) {
|
||||
let (a, c) = (w[0], w[1]);
|
||||
match (a.time, c.time) {
|
||||
(Some(t0), Some(t1)) if t1 > t0 => println!(
|
||||
" t {t0:>4} -> {t1:<4} x {:>6} -> {:<6} = {:+.4} px/unit",
|
||||
a.x, c.x, (c.x - a.x) as f64 / (t1 - t0) as f64),
|
||||
_ => println!(" t {:?} -> {:?} x {} -> {} (no rate)", a.time, c.time, a.x, c.x),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("--- END ---");
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
//! Where are the title's two light-sweep quads at a given time — and is a
|
||||
//! best-fit against a framebuffer PNG even measuring their position?
|
||||
//!
|
||||
//! `ui-leaf-vs-parent-alpha.md` solves the sweep instant as **t = 357.7** from a
|
||||
//! GPU per-draw capture: quad centre x measured off the submitted vertex buffer,
|
||||
//! which is a position measurement at 4 px/unit. The port agent separately
|
||||
//! best-fits the same leaf against `live-title-build4-no-plate.png` and gets
|
||||
//! **~400 units**, and asked whether the two used the same capture.
|
||||
//!
|
||||
//! Before comparing the numbers, check whether the second method can see what it
|
||||
//! claims to measure. A fit that is minimised by the quad being OFF-SCREEN is
|
||||
//! minimised by absence, and would return "best" at whatever time draws least —
|
||||
//! the same shape as the `.tbm` control that could not fail.
|
||||
//!
|
||||
//! So: print the leaves' own x, alpha, and on-screen overlap across the window.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example ptloop_leaf_sweep_at
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
|
||||
const SCREEN_W: i64 = 1280;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
let by = ar.read(&ar.entries()[4]).expect("entry 4");
|
||||
let b = ui_layout::parse_build(&by).expect("parse");
|
||||
|
||||
for parent in ["ptloop01", "ptloop02"] {
|
||||
let Some(el) = b.elements.iter().find(|e| e.name.starts_with(parent)) else {
|
||||
eprintln!("no element {parent}");
|
||||
continue;
|
||||
};
|
||||
let Some(&(off, size)) = b.records.get(&el.name) else {
|
||||
eprintln!("{}: no nested record", el.name);
|
||||
continue;
|
||||
};
|
||||
// `ui-record-loop-length.md`: a nested record's header `+0x08` is its
|
||||
// CYCLE LENGTH, and its keyframes need not fill it. This is why two
|
||||
// captures of the same settled title do not share a sweep phase.
|
||||
let loop_len = u32::from_be_bytes(by[off + 8..off + 12].try_into().unwrap());
|
||||
println!("\n-- {} nested record: loop length (+0x08) = {loop_len}", el.name);
|
||||
let Some(lb) = ui_layout::parse_build(&by[off..off + size]) else {
|
||||
eprintln!("{}: leaf will not parse", el.name);
|
||||
continue;
|
||||
};
|
||||
for le in &lb.elements {
|
||||
let w = (le.pivot_x * 2) as i64;
|
||||
println!(
|
||||
"\n== {} -> leaf {} (sprite {:?}, pivot {}x{}, {} keyframes, last t={:?})",
|
||||
el.name,
|
||||
le.name,
|
||||
le.sprite,
|
||||
le.pivot_x,
|
||||
le.pivot_y,
|
||||
le.keyframes.len(),
|
||||
le.keyframes.last().map(|k| k.time)
|
||||
);
|
||||
// ⚠️ A keyframe's `x` is the quad's LEFT edge, not its centre. The
|
||||
// draw-capture fit is quoted in CENTRES, so compare `centre`, which is
|
||||
// `x + pivot_x`. Printing `x` under a "centre" heading is how a
|
||||
// 200-px offset gets into a comparison unnoticed.
|
||||
println!(" t | x | centre | a | on-screen px of a {w}px-wide quad");
|
||||
for t in [
|
||||
340u32, 350, 355, 357, 358, 360, 370, 380, 390, 395, 400, 405, 410, 420, 440, 480,
|
||||
540,
|
||||
] {
|
||||
let Some(k) = le.pose_at(t) else {
|
||||
println!(" {t:>4} | (no pose)");
|
||||
continue;
|
||||
};
|
||||
let x = k.x as i64;
|
||||
let a = k.fade >> 24;
|
||||
let l = x;
|
||||
let r = l + w;
|
||||
let vis = (r.min(SCREEN_W) - l.max(0)).max(0);
|
||||
println!(
|
||||
" {t:>4} | {x:>4} | {:>6} | {a:>3} | {vis:>5} px {}",
|
||||
x + le.pivot_x as i64,
|
||||
if vis == 0 {
|
||||
"*** ENTIRELY OFF SCREEN ***"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
//! Is a nested record's header `+0x08` its LOOP LENGTH — and do its keyframes
|
||||
//! have to fill it?
|
||||
//!
|
||||
//! A `*f` focus record animates forever while its button is focused, so
|
||||
//! something must say where the cycle restarts. The keyframes cannot: the plate's
|
||||
//! `ptbtn00f` runs 0→80→0 over 105 units, and looping at 105 gives a period 15 %
|
||||
//! short of every measurement of the real thing.
|
||||
//!
|
||||
//! Each record is itself a RATC bundle with its own header, and `+0x08` is a
|
||||
//! frame count. If it is the loop length then it must never be LESS than the
|
||||
//! record's largest keyframe time — an animation cannot restart before its own
|
||||
//! last pose — and it may be more, which is a hold at the final pose.
|
||||
//!
|
||||
//! Two controls, both of which a wrong reading fails:
|
||||
//! * `+0x08 < max keyframe time` must never happen. That is the falsifier.
|
||||
//! * The distribution must not be trivial: if every record had exactly
|
||||
//! `+0x08 == max t`, the field would carry nothing and "loop length" would be
|
||||
//! an unfalsifiable relabelling of the keyframes.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let (mut total, mut exact, mut holds, mut violations) = (0usize, 0usize, 0usize, 0usize);
|
||||
let mut hold_hist: BTreeMap<i64, usize> = BTreeMap::new();
|
||||
let mut worst: Vec<(i64, String)> = Vec::new();
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for (rn, &(o, s)) in &b.records {
|
||||
if o + 12 > by.len() || o + s > by.len() { continue }
|
||||
if &by[o..o + 4] != b"RATC" { continue }
|
||||
let len = u32::from_be_bytes(by[o + 8..o + 12].try_into().unwrap()) as i64;
|
||||
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
|
||||
let maxt = lb.elements.iter()
|
||||
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max().unwrap_or(0) as i64;
|
||||
if maxt == 0 { continue } // a static record declares no cycle
|
||||
total += 1;
|
||||
let slack = len - maxt;
|
||||
*hold_hist.entry(slack).or_default() += 1;
|
||||
if slack == 0 { exact += 1 } else if slack > 0 { holds += 1 } else {
|
||||
violations += 1;
|
||||
if worst.len() < 12 {
|
||||
worst.push((slack, format!("{}:{rn} len={len} maxt={maxt}",
|
||||
p.file_name().unwrap().to_string_lossy())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("nested records with timed keyframes : {total}");
|
||||
println!(" +08 == max keyframe time (exact) : {exact} ({:.1}%)", 100.0*exact as f64/total as f64);
|
||||
println!(" +08 > max keyframe time (a hold) : {holds} ({:.1}%)", 100.0*holds as f64/total as f64);
|
||||
println!(" +08 < max keyframe time 🔴 : {violations} ({:.2}%) <- the falsifier",
|
||||
100.0*violations as f64/total as f64);
|
||||
println!("\nslack (+08 - max t) distribution, most common first:");
|
||||
let mut h: Vec<_> = hold_hist.iter().collect();
|
||||
h.sort_by_key(|&(_, n)| std::cmp::Reverse(*n));
|
||||
for (k, n) in h.iter().take(14) { println!(" slack {k:>6} : {n}"); }
|
||||
if !worst.is_empty() { println!("\nviolations:"); for (s, w) in &worst { println!(" {s:>6} {w}"); } }
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
//! Verify the newly-public `ui_layout::loop_length_units` against the disc.
|
||||
//!
|
||||
//! `sylpheed-port` reads a record's `+0x08` itself, guarded on the RATC magic,
|
||||
//! because the field was exposed on no public ref at all — example, test and
|
||||
//! `docs/re/` only. This checks the public function reproduces the numbers the
|
||||
//! finding was written from before the port depends on it.
|
||||
//!
|
||||
//! CONTROL FIRST: the function must return `None` for a non-RATC slice and for a
|
||||
//! slice too short to hold the field. An accessor that returns a number for
|
||||
//! anything cannot be trusted to return the right one.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example record_loop_length_api
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
// ---- controls -------------------------------------------------------
|
||||
assert_eq!(ui_layout::loop_length_units(b"NOTR\x00\x00\x00\x00\x00\x00\x00\x78"), None,
|
||||
"control FAILED: accepted a non-RATC slice");
|
||||
assert_eq!(ui_layout::loop_length_units(b"RATC\x00\x00"), None,
|
||||
"control FAILED: accepted a slice too short for +0x08");
|
||||
assert_eq!(ui_layout::loop_length_units(b"RATC\x00\x00\x00\x00\x00\x00\x00\x78"), Some(120),
|
||||
"control FAILED: did not read +0x08 big-endian");
|
||||
println!("controls pass: rejects non-RATC, rejects short, reads BE at +0x08");
|
||||
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
|
||||
// The records the finding names, with their published values.
|
||||
let expect: &[(&str, u32)] = &[("ptbtn00f.rat", 120), ("ptloop01.rat", 600),
|
||||
("ptloop02.rat", 720)];
|
||||
let mut seen = 0usize;
|
||||
let (mut recs, mut viol) = (0usize, 0usize);
|
||||
|
||||
for (ei, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for (name, (off, size)) in &b.records {
|
||||
let rec = &by[*off..(*off + *size).min(by.len())];
|
||||
let Some(len) = ui_layout::loop_length_units(rec) else { continue };
|
||||
recs += 1;
|
||||
// the disc-wide invariant the finding rests on
|
||||
let largest = ui_layout::parse_build(rec)
|
||||
.map(|l| l.elements.iter()
|
||||
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max().unwrap_or(0))
|
||||
.unwrap_or(0);
|
||||
if len < largest { viol += 1; }
|
||||
for (want_name, want) in expect {
|
||||
if name == want_name && seen < 16 {
|
||||
seen += 1;
|
||||
let ok = if len == *want { "OK" } else { "MISMATCH" };
|
||||
println!(" entry {ei:2} {name:16} +0x08 = {len:4} \
|
||||
(published {want}) largest kf {largest:4} {ok}");
|
||||
assert_eq!(len, *want, "{name} disagrees with the published value");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\n{recs} records read through the public fn; \
|
||||
{viol} violate +0x08 >= largest keyframe time");
|
||||
assert!(seen > 0, "found none of the named records — the check proved nothing");
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
//! Does "1 697 fallback fires return a visible pose" survive being said out loud?
|
||||
//!
|
||||
//! `rest-fallback-census.txt` reports that of 2 305 elements where the dwell
|
||||
//! fallback decides, 1 697 rest at `alpha > 0`. It was written as if that number
|
||||
//! were the defect. **It is only a defect where the element is a transient.** An
|
||||
//! element that genuinely ends visible and stays visible SHOULD rest visible, and
|
||||
//! the fallback happening to be the path that got there is not an error.
|
||||
//!
|
||||
//! The port agent hit the mirror image of this: it counted a screen's own exit
|
||||
//! ramp as the end of an element's visibility, so `ptmsg` — the main menu's
|
||||
//! permanent footer — came out as "a 2-unit flash". The story collapsed when
|
||||
//! said aloud. This asks the same question of my number.
|
||||
//!
|
||||
//! Split the 1 697 by what the element's LAST keyframe does:
|
||||
//! * last alpha > 0 -> the element ends visible; resting visible is right
|
||||
//! * last alpha == 0 -> it fades out; a visible rest is a transient's peak
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example rest_fallback_audit
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
|
||||
paks.sort();
|
||||
let (mut fires, mut vis, mut ends_visible, mut ends_zero, mut at_peak) = (0, 0, 0, 0, 0);
|
||||
// ⚠️ The port agent's exit-ramp finding applies to THIS split too: if a
|
||||
// screen's exit ramp drives every element to a=0, then "last keyframe a=0"
|
||||
// says nothing about the element being a transient. Measure it on ALL
|
||||
// elements before using it on the 1 697.
|
||||
let (mut all_el, mut all_end_zero) = (0usize, 0usize);
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for el in &b.elements {
|
||||
if el.keyframes.len() < 2 { continue }
|
||||
all_el += 1;
|
||||
if (el.keyframes.last().unwrap().fade >> 24) & 0xff == 0 { all_end_zero += 1 }
|
||||
if el.keyframes.windows(2).any(|w| w[0].x == w[1].x && w[0].y == w[1].y
|
||||
&& w[0].scale_x == w[1].scale_x && w[0].scale_y == w[1].scale_y
|
||||
&& w[0].fade == w[1].fade) { continue }
|
||||
fires += 1;
|
||||
let Some(r) = el.rest() else { continue };
|
||||
let a = (r.fade >> 24) & 0xff;
|
||||
if a == 0 { continue }
|
||||
vis += 1;
|
||||
let last = (el.keyframes.last().unwrap().fade >> 24) & 0xff;
|
||||
if last > 0 { ends_visible += 1 } else { ends_zero += 1 }
|
||||
let peak = el.keyframes.iter().map(|k| (k.fade >> 24) & 0xff).max().unwrap_or(0);
|
||||
if a == peak { at_peak += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("fallback fires {fires}");
|
||||
println!(" of those, rest alpha > 0 {vis}");
|
||||
println!(" element's LAST keyframe alpha > 0 {ends_visible} <- ends visible; resting visible is CORRECT");
|
||||
println!(" element's LAST keyframe alpha = 0 {ends_zero} <- fades out; a visible rest is a transient's peak");
|
||||
println!(" rest alpha == the element's MAX {at_peak}");
|
||||
println!("\nCONTROL on the split itself — is 'ends at a=0' near-universal?");
|
||||
println!(" all elements with >= 2 keyframes {all_el}");
|
||||
println!(" of those, last keyframe alpha = 0 {all_end_zero} ({:.1} %)",
|
||||
100.0 * all_end_zero as f64 / all_el as f64);
|
||||
println!("\n--- END (if this line is missing, the run did not finish) ---");
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
//! When the resting-pose DWELL FALLBACK actually runs, does it pick a visible pose?
|
||||
//!
|
||||
//! `ui-resting-pose.md` argues the fallback is structurally unsound — the gap it
|
||||
//! maximises is time spent *interpolating*, so neither endpoint is held. Its one
|
||||
//! worked example, `GP_TITLE` build 7's `ptlogo_eff3.t32`, **no longer
|
||||
//! discriminates**: under the corrected keyframe-record layout the longest gap
|
||||
//! moved from `61→103` to `0→46`, and both ends of that are `a = 0`. The page's
|
||||
//! listing still shows the stale parser's trailing `-`.
|
||||
//!
|
||||
//! Losing the example is not the same as closing the question, so: disc-wide, how
|
||||
//! often does the fallback fire, and when it does, does it return something the
|
||||
//! player would see? An element resting at `a = 0` is harmless whichever end the
|
||||
//! rule lands on.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example rest_fallback_census
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
|
||||
let (mut elements, mut plateau, mut fallback, mut fb_visible) = (0usize, 0, 0, 0);
|
||||
let mut worst: Vec<(u32, String, String)> = Vec::new();
|
||||
let mut per_pak: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
let name = pak.file_name().unwrap().to_string_lossy().to_string();
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for el in &b.elements {
|
||||
if el.keyframes.len() < 2 { continue }
|
||||
elements += 1;
|
||||
// a plateau is two ADJACENT poses that are equal — the same test
|
||||
// the plateau path makes before the fallback can run
|
||||
let has_plateau = el.keyframes.windows(2).any(|w| {
|
||||
w[0].x == w[1].x && w[0].y == w[1].y
|
||||
&& w[0].scale_x == w[1].scale_x && w[0].scale_y == w[1].scale_y
|
||||
&& w[0].fade == w[1].fade
|
||||
});
|
||||
if has_plateau { plateau += 1; continue }
|
||||
fallback += 1;
|
||||
per_pak.entry(name.clone()).or_default().0 += 1;
|
||||
let Some(r) = el.rest() else { continue };
|
||||
let a = (r.fade >> 24) & 0xff;
|
||||
if a > 0 {
|
||||
fb_visible += 1;
|
||||
per_pak.entry(name.clone()).or_default().1 += 1;
|
||||
worst.push((a, name.clone(), format!("e{i}/{}", el.name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("POPULATION: {elements} elements with >= 2 keyframes, over {} archives", paks.len());
|
||||
println!("COVERAGE: {plateau} have a plateau (fallback never runs)");
|
||||
println!(" {fallback} have NONE -> the dwell fallback decides");
|
||||
println!(" {fb_visible} of those rest at alpha > 0 -- i.e. VISIBLE\n");
|
||||
println!("PER ARCHIVE — fallback fires / of those, rests VISIBLE:");
|
||||
let mut rows: Vec<_> = per_pak.into_iter().collect();
|
||||
rows.sort_by(|a, b| b.1.1.cmp(&a.1.1));
|
||||
for (pak, (fires, vis)) in &rows {
|
||||
println!(" {pak:34} {fires:5} fires {vis:5} visible");
|
||||
}
|
||||
println!();
|
||||
worst.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
for (a, pak, el) in worst.iter().take(6) {
|
||||
println!(" a={a:3} {pak} {el}");
|
||||
}
|
||||
println!("\n--- END OF CENSUS (if this line is missing, the run did not finish) ---");
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
//! Which `GP_TITLE` elements does the resting-pose dwell fallback decide, and does
|
||||
//! it hand back a visible pose? The disc-wide census says 5 fires / 4 visible here.
|
||||
//! cargo run -p sylpheed-formats --example rest_fallback_title
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for el in &b.elements {
|
||||
if el.keyframes.len() < 2 { continue }
|
||||
if el.keyframes.windows(2).any(|w| w[0].x == w[1].x && w[0].y == w[1].y
|
||||
&& w[0].scale_x == w[1].scale_x && w[0].scale_y == w[1].scale_y
|
||||
&& w[0].fade == w[1].fade) { continue }
|
||||
let Some(r) = el.rest() else { continue };
|
||||
let a = (r.fade >> 24) & 0xff;
|
||||
let ks: Vec<String> = el.keyframes.iter()
|
||||
.map(|k| format!("{}:a{}", k.time.map(|v| v.to_string()).unwrap_or("-".into()), (k.fade >> 24) & 0xff))
|
||||
.collect();
|
||||
println!("entry {i:2} {:24} rest a={a:3} t={:?} [{}]{}",
|
||||
el.name, r.time, ks.join(" "),
|
||||
if a > 0 { " <== VISIBLE" } else { "" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
//! The resting SCALE of each element, so a drawn quad's size can be predicted.
|
||||
//!
|
||||
//! One additive draw on both the main menu and `EXTRAS` measures 819.2 x 720 px
|
||||
//! and matches no sprite at 1x or 2x. Scale is the missing factor: the keyframe
|
||||
//! carries scale_x / scale_y in percent, and a sprite drawn at 200 % x 500 % is
|
||||
//! nothing like its stored size.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example rest_scale_of -- 5 6 4
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
let pak = argv.iter().find(|a| a.parse::<usize>().is_err())
|
||||
.cloned().unwrap_or_else(|| "GP_TITLE".to_string());
|
||||
let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak");
|
||||
let builds: Vec<usize> = argv.iter().filter_map(|a| a.parse().ok()).collect();
|
||||
for build in if builds.is_empty() { vec![5usize, 6] } else { builds } {
|
||||
let Ok(by) = ar.read(&ar.entries()[build]) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
println!("=== {pak} entry {build} ===");
|
||||
println!("{:<22} {:>10} {:>7} {:>7} {:>12} {}", "element", "pivot(w,h)", "sx%", "sy%", "drawn px", "at 1x/2x of pivot*2");
|
||||
for e in &b.elements {
|
||||
let k = match e.rest() { Some(k) => k, None => continue };
|
||||
let w = e.pivot_x * 2;
|
||||
let h = e.pivot_y * 2;
|
||||
let dw = w as f64 * k.scale_x as f64 / 100.0;
|
||||
let dh = h as f64 * k.scale_y as f64 / 100.0;
|
||||
println!("{:<22} {:>4},{:<5} {:>7} {:>7} {:>6.1}x{:<5.1} {:>6.1}x{:<5.1}",
|
||||
e.name, w, h, k.scale_x, k.scale_y, dw, dh, dw * 2.0, dh * 2.0);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
//! Should the settled pose come from each element's `rest()`, or from the
|
||||
//! **screen's** settle instant?
|
||||
//!
|
||||
//! Three iterations have measured how badly `rest()`'s dwell fallback behaves —
|
||||
//! 2 305 elements where it decides, 1 457 of them handed the element's *maximum*
|
||||
//! alpha, and by construction none of those poses is held. What has been missing
|
||||
//! is a proposal.
|
||||
//!
|
||||
//! `UiBuild::settle_time()` already exists: the midpoint of the longest
|
||||
//! keyframe-free interval **across the whole build**. That is the port agent's
|
||||
//! "re-key on the screen's span rather than the element's", and its shipped path
|
||||
//! poses `pose_at(hold)` and agrees with every capture it holds at 0.01 %.
|
||||
//!
|
||||
//! ⚠️ **Control first.** On elements where `rest()` is already sound — the plateau
|
||||
//! path, a pose the element genuinely holds — `pose_at(settle)` must AGREE. If it
|
||||
//! disagrees there, it is not a better rule, it is a different one.
|
||||
//!
|
||||
//! ⚠️ `ui-settle-time.md` records that 42 % of bundles have a settle window under
|
||||
//! 10 units and never settle at all. Bundles are split on that here rather than
|
||||
//! averaged over.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example rest_vs_settle
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
|
||||
paks.sort();
|
||||
// control population (plateau) and test population (fallback), each split by
|
||||
// whether the bundle settles at all
|
||||
let (mut ctl_n, mut ctl_cov, mut ctl_agree) = (0usize, 0usize, 0usize);
|
||||
let (mut fb_n, mut fb_rest_vis, mut fb_settle_vis) = (0usize, 0usize, 0usize);
|
||||
let mut narrow = 0usize;
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let Some((lo, hi)) = b.settle_window() else { continue };
|
||||
if hi - lo < 10 { narrow += 1; continue } // this bundle never settles
|
||||
let st = lo + (hi - lo) / 2;
|
||||
for el in &b.elements {
|
||||
if el.keyframes.len() < 2 { continue }
|
||||
let plateau = el.keyframes.windows(2).any(|w| w[0].x == w[1].x
|
||||
&& w[0].y == w[1].y && w[0].scale_x == w[1].scale_x
|
||||
&& w[0].scale_y == w[1].scale_y && w[0].fade == w[1].fade);
|
||||
let (Some(r), Some(s)) = (el.rest(), el.pose_at(st)) else { continue };
|
||||
let (ra, sa) = ((r.fade >> 24) & 0xff, (s.fade >> 24) & 0xff);
|
||||
if plateau {
|
||||
// ⚠️ The first version of this control compared EVERY plateau
|
||||
// element and got 46.6 % agreement — then I asked what that
|
||||
// means physically. `rest()` finds *a* held pose; many
|
||||
// elements hold one during the build-in and then move on.
|
||||
// `pose_at(settle)` asks what is on screen WHEN THE SCREEN HAS
|
||||
// SETTLED. Those are different questions, so disagreement
|
||||
// proves nothing. The fair control is the subset where the
|
||||
// held interval actually CONTAINS the settle instant.
|
||||
ctl_n += 1;
|
||||
let covers = el.keyframes.windows(2).any(|w| {
|
||||
let held = w[0].x == w[1].x && w[0].y == w[1].y
|
||||
&& w[0].scale_x == w[1].scale_x
|
||||
&& w[0].scale_y == w[1].scale_y && w[0].fade == w[1].fade;
|
||||
match (w[0].time, w[1].time) {
|
||||
(Some(a), Some(bb)) => held && a <= st && st <= bb,
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
if covers {
|
||||
ctl_cov += 1;
|
||||
if ra == sa && r.x == s.x && r.y == s.y
|
||||
&& r.scale_x == s.scale_x && r.scale_y == s.scale_y { ctl_agree += 1 }
|
||||
}
|
||||
} else {
|
||||
fb_n += 1;
|
||||
if ra > 0 { fb_rest_vis += 1 }
|
||||
if sa > 0 { fb_settle_vis += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("bundles skipped as never-settling (window < 10 units): {narrow}\n");
|
||||
println!("CONTROL — elements where rest() takes the SOUND plateau path:");
|
||||
println!(" {ctl_n} plateau elements in settling bundles");
|
||||
println!(" {ctl_cov} of them HOLD ACROSS the settle instant — the fair control");
|
||||
println!(" pose_at(settle) agrees with rest() on {ctl_agree} of those ({:.1} %)",
|
||||
100.0 * ctl_agree as f64 / ctl_cov.max(1) as f64);
|
||||
println!("\nTEST — elements where the unsound dwell fallback decides:");
|
||||
println!(" {fb_n} elements");
|
||||
println!(" rest() returns a VISIBLE pose on {fb_rest_vis} ({:.1} %)",
|
||||
100.0 * fb_rest_vis as f64 / fb_n.max(1) as f64);
|
||||
println!(" pose_at(settle) returns a VISIBLE pose on {fb_settle_vis} ({:.1} %)",
|
||||
100.0 * fb_settle_vis as f64 / fb_n.max(1) as f64);
|
||||
println!("\n--- END (if this line is missing, the run did not finish) ---");
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//! Every scale value on the disc's UI, parents AND nested leaves.
|
||||
//!
|
||||
//! `DECISIONS.md` records `ptlogo_eff2` at 125 % as "the single drawn element in
|
||||
//! the whole export at a scale that is not a whole multiple of 100 %". That
|
||||
//! census was over parents only -- leaves were never opened. This opens them.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("pak");
|
||||
let ar = pak::PakArchive::open(&path).expect("open");
|
||||
let mut hist: BTreeMap<(u32, u32), Vec<String>> = BTreeMap::new();
|
||||
let mut leaves_opened = 0usize;
|
||||
for (i, e) in ar.entries().to_vec().iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&bytes) else { continue };
|
||||
for el in &b.elements {
|
||||
for k in &el.keyframes {
|
||||
hist.entry((k.scale_x, k.scale_y))
|
||||
.or_default()
|
||||
.push(format!("e{i}/{}", el.name));
|
||||
}
|
||||
if let Some(&(off, size)) = b.records.get(&el.name) {
|
||||
if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) {
|
||||
leaves_opened += 1;
|
||||
for le in &lb.elements {
|
||||
for k in &le.keyframes {
|
||||
hist.entry((k.scale_x, k.scale_y))
|
||||
.or_default()
|
||||
.push(format!("e{i}/{}->LEAF/{}", el.name, le.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{leaves_opened} leaves opened\n");
|
||||
println!("{:>12} {:>7} examples", "scale", "count");
|
||||
for (k, v) in &hist {
|
||||
let mut ex: Vec<&String> = v.iter().collect();
|
||||
ex.sort(); ex.dedup();
|
||||
let odd = k.0 % 100 != 0 || k.1 % 100 != 0;
|
||||
println!("{}{:>5},{:<5} {:>7} {}", if odd { "* " } else { " " },
|
||||
k.0, k.1, v.len(),
|
||||
ex.iter().take(3).map(|s| s.as_str()).collect::<Vec<_>>().join(", "));
|
||||
}
|
||||
println!("\n* = not a whole multiple of 100%");
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//! How often does posing at the SCREEN's settle instant catch an element
|
||||
//! mid-ramp? The adversarial census of my own proposal.
|
||||
//!
|
||||
//! The port agent found `ptmsg` — the main menu's footer — at alpha **127.5 of
|
||||
//! 255** at that screen's settle instant, because the longest keyframe-free
|
||||
//! interval ends exactly as the footer starts to arrive. `screen render --settle`
|
||||
//! already prints "⚠️ narrow — this bundle may never settle" there: the window is
|
||||
//! **12 units**.
|
||||
//!
|
||||
//! ⚠️ **And my `rest_vs_settle` filter was too permissive**: it dropped bundles
|
||||
//! with a window under 10 units, so a 12-unit window passed while the tool itself
|
||||
//! was flagging it. This splits by width instead of picking one cutoff.
|
||||
//!
|
||||
//! "Mid-ramp" = at the settle instant the element sits strictly inside an interval
|
||||
//! whose two endpoint poses DIFFER — it is interpolating, not held.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example settle_midramp_census
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
|
||||
paks.sort();
|
||||
// buckets by settle-window width
|
||||
let edges = [0u32, 10, 20, 30, 60, u32::MAX];
|
||||
let names = ["< 10", "10–19", "20–29", "30–59", ">= 60"];
|
||||
let mut els = [0usize; 5];
|
||||
let mut mid = [0usize; 5];
|
||||
let mut bundles = [0usize; 5];
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let Some((lo, hi)) = b.settle_window() else { continue };
|
||||
let w = hi - lo;
|
||||
let bi = edges.windows(2).position(|p| w >= p[0] && w < p[1]).unwrap_or(4);
|
||||
bundles[bi] += 1;
|
||||
let st = lo + w / 2;
|
||||
for el in &b.elements {
|
||||
let k = &el.keyframes;
|
||||
if k.len() < 2 { continue }
|
||||
els[bi] += 1;
|
||||
// the interval containing the settle instant
|
||||
let mut interpolating = false;
|
||||
for pair in k.windows(2) {
|
||||
if let (Some(t0), Some(t1)) = (pair[0].time, pair[1].time) {
|
||||
if t0 <= st && st <= t1 && t0 != t1 {
|
||||
let same = pair[0].fade == pair[1].fade
|
||||
&& pair[0].x == pair[1].x && pair[0].y == pair[1].y
|
||||
&& pair[0].scale_x == pair[1].scale_x
|
||||
&& pair[0].scale_y == pair[1].scale_y;
|
||||
if !same && st != t0 && st != t1 { interpolating = true }
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if interpolating { mid[bi] += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{:8}{:>10}{:>10}{:>12}{:>10}", "window", "bundles", "elements", "mid-ramp", "share");
|
||||
for i in 0..5 {
|
||||
if els[i] == 0 { continue }
|
||||
println!("{:8}{:>10}{:>10}{:>12}{:>9.1}%", names[i], bundles[i], els[i], mid[i],
|
||||
100.0 * mid[i] as f64 / els[i] as f64);
|
||||
}
|
||||
let te: usize = els.iter().sum(); let tm: usize = mid.iter().sum();
|
||||
println!("{:8}{:>10}{:>10}{:>12}{:>9.1}%", "ALL", bundles.iter().sum::<usize>(), te, tm,
|
||||
100.0 * tm as f64 / te as f64);
|
||||
println!("\n--- END (if this line is missing, the run did not finish) ---");
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//! What share of bundles have a NARROW settle window -- and of WHICH bundles?
|
||||
//!
|
||||
//! `screen render --settle`'s help says "a narrow one means the bundle never
|
||||
//! settles (42 % of them, mostly `loop*` fragments)". The 42 % is correct and is
|
||||
//! stated precisely in ui-settle-time.md: 731 of **1 758 composable bundles
|
||||
//! carrying two or more keyframe times**. But inside `screen render`, "them"
|
||||
//! reads as the bundles you would render -- the SCREEN BUILDS -- which is a
|
||||
//! different and much smaller population. This computes both.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example settle_narrow_rate
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
|
||||
paks.sort();
|
||||
// (with >=2 keyframe times, narrow) for each population
|
||||
let (mut b2, mut bn) = (0usize, 0usize); // screen builds (is_build)
|
||||
let (mut c2, mut cn) = (0usize, 0usize); // composable (is_composable)
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let is_b = ui_layout::is_build(&by);
|
||||
let is_c = ui_layout::is_composable(&by);
|
||||
if !is_b && !is_c { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let mut ts: Vec<u32> = b.elements.iter()
|
||||
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)).collect();
|
||||
ts.sort_unstable(); ts.dedup();
|
||||
if ts.len() < 2 { continue }
|
||||
let narrow = match b.settle_window() { Some((lo, hi)) => hi - lo < 10, None => true };
|
||||
if is_b { b2 += 1; if narrow { bn += 1 } }
|
||||
if is_c { c2 += 1; if narrow { cn += 1 } }
|
||||
}
|
||||
}
|
||||
println!("population n narrow (<10 u) share");
|
||||
println!("SCREEN BUILDS (is_build, what `screen render` renders by default)");
|
||||
println!(" {b2:5} {bn:9} {:.0} %",
|
||||
100.0 * bn as f64 / b2.max(1) as f64);
|
||||
println!("COMPOSABLE bundles (is_composable, what --all admits)");
|
||||
println!(" {c2:5} {cn:9} {:.0} %",
|
||||
100.0 * cn as f64 / c2.max(1) as f64);
|
||||
println!("\nui-settle-time.md quotes 731 / 1758 = 42 % over composable bundles.");
|
||||
println!("--- END ---");
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// The settled screen, computed from the keyframe times alone.
|
||||
//
|
||||
// `rest()` picks each element's last HOLD keyframe independently, which is right
|
||||
// for an element that ends settled and wrong for a transient: a 2-frame flash
|
||||
// holds at its PEAK, so `rest()` leaves it burning forever. The settled screen is
|
||||
// instead one INSTANT that every element is posed at, and the instant to pick is
|
||||
// inside the longest interval during which no element has a keyframe at all.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap();
|
||||
let ar=pak::PakArchive::open(&pk).unwrap();
|
||||
let only:Option<usize>=a.next().and_then(|s|s.parse().ok());
|
||||
for (i,e) in ar.entries().iter().enumerate() {
|
||||
if let Some(o)=only { if o!=i { continue } }
|
||||
let Ok(by)=ar.read(e) else { continue };
|
||||
let Some(b)=ui_layout::parse_build(&by) else { continue };
|
||||
if b.elements.len()<2 { continue }
|
||||
let mut ts:Vec<u32>=b.elements.iter().flat_map(|el|
|
||||
el.keyframes.iter().filter_map(|k|k.time)).collect();
|
||||
if ts.len()<2 { continue }
|
||||
ts.sort_unstable(); ts.dedup();
|
||||
// longest gap between consecutive keyframe times
|
||||
let (mut best,mut lo,mut hi)=(0u32,0u32,0u32);
|
||||
for w in ts.windows(2) {
|
||||
if w[1]-w[0] > best { best=w[1]-w[0]; lo=w[0]; hi=w[1]; }
|
||||
}
|
||||
println!("{:>4} {:<28} times {:>3} span {:>4} settle window [{lo},{hi}] = {best} units ({:.2}s) -> t={}",
|
||||
i, format!("{:08x}",e.name_hash), ts.len(), ts.last().unwrap(), best as f64/60.0, lo+best/2);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
//! Where does `settle_window()`'s answer come from? The port agent recomputes the
|
||||
//! publisher splash's widest keyframe-free gap as 190 units; `--settle` reports 8.
|
||||
//! One of the two readings is wrong and the file settles it.
|
||||
//! cargo run -p sylpheed-formats --example settle_window_check -- <build>
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
fn main() {
|
||||
let b: usize = std::env::args().nth(1).unwrap_or("10".into()).parse().unwrap();
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
let by = ar.read(&ar.entries()[b]).expect("entry");
|
||||
let build = ui_layout::parse_build(&by).expect("parse");
|
||||
println!("entry {b}: {} elements", build.elements.len());
|
||||
for el in &build.elements {
|
||||
let ts: Vec<String> = el.keyframes.iter()
|
||||
.map(|k| k.time.map(|v| v.to_string()).unwrap_or("-".into())).collect();
|
||||
println!(" {:26} [{}]", el.name, ts.join(" "));
|
||||
}
|
||||
let mut ts: Vec<u32> = build.elements.iter()
|
||||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)).collect();
|
||||
ts.sort_unstable(); ts.dedup();
|
||||
println!("\nunion of all element keyframe times: {ts:?}");
|
||||
let gaps: Vec<(u32,u32,u32)> = ts.windows(2).map(|w| (w[1]-w[0], w[0], w[1])).collect();
|
||||
let mut g = gaps.clone(); g.sort_by(|a,b| b.0.cmp(&a.0));
|
||||
println!("widest gaps: {:?}", &g[..g.len().min(4)]);
|
||||
println!("settle_window() reports {:?}", build.settle_window());
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//! Out-of-sample test of the `T8aD +0x04` blend bit on the **boot splashes**.
|
||||
//!
|
||||
//! `ui-blend-mode-decoded.md` established the field on 35 elements over three
|
||||
//! screens (GP_TITLE entries 2, 4, 5, 6). The two splashes -- entries 10 and 11 --
|
||||
//! were NOT in that sample, and they are the screens the 2026-09-01 play-test
|
||||
//! says are wrong.
|
||||
//!
|
||||
//! The oracle for these two screens is `data/splash-draw-pass-census.txt`: over
|
||||
//! **1048 draws of frames 4..226**, covering both splashes end to end, the only
|
||||
//! blend states submitted are `0x00010001` (the clear) and `0x07010701`
|
||||
//! (source-over). Additive, `0x01010101`, appears **zero** times.
|
||||
//!
|
||||
//! PRE-REGISTERED PREDICTION, written before reading the disc: if the bit is the
|
||||
//! blend selector and it generalises off its training screens, then every sprite
|
||||
//! in entries 10 and 11 must report `additive = false`. Any `true` is a
|
||||
//! discrepancy the field has to answer for.
|
||||
//!
|
||||
//! cargo run --release -p sylpheed-formats --example splash_blend_check
|
||||
use std::path::PathBuf;
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
|
||||
let mut additive = 0usize;
|
||||
let mut alpha_over = 0usize;
|
||||
let mut no_header = 0usize;
|
||||
|
||||
for entry in [10usize, 11] {
|
||||
let by = ar.read(&ar.entries()[entry]).expect("entry");
|
||||
let Some(b) = ui_layout::parse_build(&by) else {
|
||||
println!("entry {entry}: not a build");
|
||||
continue;
|
||||
};
|
||||
println!("\n## GP_TITLE entry {entry} -- {} elements, {} sprites",
|
||||
b.elements.len(), b.sprites.len());
|
||||
|
||||
// Every sprite the bundle carries, not only those a top-level element
|
||||
// names: a focused variant is reached through `focus_link` and would
|
||||
// otherwise be invisible to this check.
|
||||
let mut names: Vec<&String> = b.sprites.keys().collect();
|
||||
names.sort();
|
||||
for n in names {
|
||||
match ui_layout::blend_additive_by_name(&b, &by, n) {
|
||||
Some(true) => { additive += 1;
|
||||
println!(" {n:<26} word04=0x{:08X} ADDITIVE",
|
||||
ui_layout::header_word_04_by_name(&b, &by, n).unwrap()); }
|
||||
Some(false) => { alpha_over += 1;
|
||||
println!(" {n:<26} word04=0x{:08X} alpha-over",
|
||||
ui_layout::header_word_04_by_name(&b, &by, n).unwrap()); }
|
||||
None => { no_header += 1; println!(" {n:<26} (no T8aD header)"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nadditive={additive} alpha-over={alpha_over} no-header={no_header}");
|
||||
println!("oracle (splash-draw-pass-census.txt, 1048 draws): additive draws = 0");
|
||||
if additive == 0 {
|
||||
println!("PREDICTION HELD -- the bit agrees with the capture on both splashes");
|
||||
} else {
|
||||
println!("PREDICTION FAILED -- {additive} sprite(s) claim additive, \
|
||||
the capture submits none");
|
||||
}
|
||||
|
||||
// Control: this harness must be able to REPORT additive, or "additive=0"
|
||||
// is a property of the harness and not of the splashes. Entry 6 is a screen
|
||||
// the oracle measures as mixed.
|
||||
let by6 = ar.read(&ar.entries()[6]).expect("entry 6");
|
||||
let b6 = ui_layout::parse_build(&by6).expect("build 6");
|
||||
let c_add = b6.sprites.keys()
|
||||
.filter(|n| ui_layout::blend_additive_by_name(&b6, &by6, n) == Some(true)).count();
|
||||
println!("control -- entry 6 through the SAME code path reports additive={c_add} \
|
||||
(must be > 0): {}", if c_add > 0 { "PASS" } else { "FAIL" });
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
//! Name the capture's eight anonymous splash quads, from the disc.
|
||||
//!
|
||||
//! `data/splash-quad-timeline.txt` recorded eight distinct NDC rects off the
|
||||
//! guest's vertex stream and could only call them Q0..Q7 -- a draw capture sees
|
||||
//! geometry, not names. This predicts each rect from the DECLARED position and
|
||||
//! the DECODED sprite size in `GP_TITLE.pak` entries 10 and 11, and matches.
|
||||
//!
|
||||
//! x_ndc = 2*x/1280 - 1 y_ndc = 1 - 2*y/720 (y down on screen)
|
||||
//!
|
||||
//! Instruments: the prediction is ⟨disc⟩ -- `parse_build` + `t8ad::parse`, i.e.
|
||||
//! OUR reader. The target is ⟨capture⟩ -- the oracle's vertex stream. So an
|
||||
//! agreement here is not a claim resting on the reader: it VALIDATES the reader
|
||||
//! on the two splash bundles, which is what `REFUTED.md`'s 🟡 `⟨our-reader⟩`
|
||||
//! entry on the splash timeline asks for. A disagreement would indict the reader.
|
||||
//!
|
||||
//! CONTROL: an assignment is only meaningful if the rects are separable, so this
|
||||
//! reports the runner-up distance for every sprite. If the best and second-best
|
||||
//! were comparable, "8/8 matched" would be an artefact of eight similar boxes.
|
||||
//!
|
||||
//! cargo run --release -p sylpheed-formats --example splash_quad_names
|
||||
use std::path::PathBuf;
|
||||
use sylpheed_formats::{pak::PakArchive, t8ad, ui_layout};
|
||||
|
||||
/// The oracle. Verbatim from the header of `docs/re/data/splash-quad-timeline.txt`,
|
||||
/// which read them off the vertex buffer. Quantised to 0.01 by that file.
|
||||
const CAPTURED: &[(&str, f64, f64, f64, f64, usize)] = &[
|
||||
// name, x0, x1, y0, y1, draws submitted in
|
||||
("Q0", -0.520, 0.520, -0.100, 0.080, 111),
|
||||
("Q1", -0.390, 0.390, 0.350, 0.550, 87),
|
||||
("Q2", -0.190, 0.190, -0.120, 0.120, 87),
|
||||
("Q3", -0.300, 0.300, -0.620, -0.250, 87),
|
||||
("Q4", -0.410, 0.410, 0.320, 0.570, 21),
|
||||
("Q5", -0.200, 0.210, -0.150, 0.150, 21),
|
||||
("Q6", -0.320, 0.310, -0.650, -0.220, 21),
|
||||
("Q7", -0.530, 0.540, -0.130, 0.120, 8),
|
||||
];
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
|
||||
// Predict a rect for every sprite-bearing element of the two splash builds.
|
||||
let mut predicted: Vec<(String, f64, f64, f64, f64)> = Vec::new();
|
||||
for entry in [10usize, 11] {
|
||||
let by = ar.read(&ar.entries()[entry]).expect("entry");
|
||||
let b = ui_layout::parse_build(&by).expect("build");
|
||||
for el in &b.elements {
|
||||
let Some(sprite) = el.sprite.as_ref() else { continue };
|
||||
let Some(&(off, size)) = b.sprites.get(sprite) else { continue };
|
||||
let Some(img) = t8ad::parse(&by[off..off + size]) else { continue };
|
||||
// Declared placement is constant across every keyframe on these
|
||||
// eight elements, so any keyframe gives the same rect; take the first.
|
||||
let Some(kf) = el.keyframes.first() else { continue };
|
||||
let (x, y) = (kf.x as f64, kf.y as f64);
|
||||
let (w, h) = (img.width as f64, img.height as f64);
|
||||
predicted.push((
|
||||
sprite.clone(),
|
||||
2.0 * x / 1280.0 - 1.0,
|
||||
2.0 * (x + w) / 1280.0 - 1.0,
|
||||
1.0 - 2.0 * (y + h) / 720.0,
|
||||
1.0 - 2.0 * y / 720.0,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let dist = |p: &(String, f64, f64, f64, f64), c: &(&str, f64, f64, f64, f64, usize)| {
|
||||
(p.1 - c.1).abs().max((p.2 - c.2).abs())
|
||||
.max((p.3 - c.3).abs()).max((p.4 - c.4).abs())
|
||||
};
|
||||
|
||||
// The capture rounds to 0.01, so a correct prediction must land inside half
|
||||
// a step plus the pixel grid: 0.01 NDC is 6.4 px in x, 3.6 px in y.
|
||||
const TOL: f64 = 0.010;
|
||||
println!("{:<26} {:<4} {:>8} {:>9} {:>7} {}",
|
||||
"sprite (disc)", "quad", "max|d|", "runner-up", "draws", "verdict");
|
||||
let (mut ok, mut bad) = (0, 0);
|
||||
let mut used: Vec<&str> = Vec::new();
|
||||
for p in &predicted {
|
||||
let mut ds: Vec<(f64, &(&str, f64, f64, f64, f64, usize))> =
|
||||
CAPTURED.iter().map(|c| (dist(p, c), c)).collect();
|
||||
ds.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
||||
let (d0, best) = (ds[0].0, ds[0].1);
|
||||
let d1 = ds[1].0;
|
||||
let good = d0 <= TOL && !used.contains(&best.0);
|
||||
if good { ok += 1; used.push(best.0) } else { bad += 1 }
|
||||
println!("{:<26} {:<4} {:>8.4} {:>9.4} {:>7} {}",
|
||||
p.0, best.0, d0, d1, best.5,
|
||||
if good { "OK" } else { "MISMATCH" });
|
||||
}
|
||||
println!("\n{ok} named, {bad} unmatched, of {} predicted / {} captured",
|
||||
predicted.len(), CAPTURED.len());
|
||||
println!("CONTROL: every runner-up above must be far outside the {TOL} tolerance; \
|
||||
if it is not, the rects are not separable and the naming is luck.");
|
||||
}
|
||||
|
||||
/// The second question this data answers, appended as its own pass: what IS an
|
||||
/// `_eff` companion? `splash-quad-timeline.txt` called them "the same rects
|
||||
/// scaled slightly larger", which is an inference from four rounded NDC numbers.
|
||||
/// The disc says otherwise, and the difference matters to anyone drawing them.
|
||||
#[test]
|
||||
fn eff_is_a_concentric_outset_not_a_scale() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
for (entry, pairs) in [
|
||||
(10usize, &[("palogo_sqex", "palogo_sqex_eff")][..]),
|
||||
(11, &[("palogo_gamearts", "palogo_gamearts_eff"),
|
||||
("palogo_seta", "palogo_seta_eff"),
|
||||
("palogo_anima", "palogo_anima_eff")][..]),
|
||||
] {
|
||||
let by = ar.read(&ar.entries()[entry]).expect("entry");
|
||||
let b = ui_layout::parse_build(&by).expect("build");
|
||||
for (logo, eff) in pairs {
|
||||
let g = |n: &str| {
|
||||
let el = b.elements.iter()
|
||||
.find(|e| e.sprite.as_deref() == Some(&format!("{n}.t32"))).unwrap();
|
||||
let &(off, size) = b.sprites.get(&format!("{n}.t32")).unwrap();
|
||||
let img = t8ad::parse(&by[off..off + size]).unwrap();
|
||||
let kf = el.keyframes.first().unwrap();
|
||||
(kf.x as f64, kf.y as f64, img.width as f64, img.height as f64)
|
||||
};
|
||||
let (lx, ly, lw, lh) = g(logo);
|
||||
let (ex, ey, ew, eh) = g(eff);
|
||||
// concentric?
|
||||
let dcx = (ex + ew / 2.0) - (lx + lw / 2.0);
|
||||
let dcy = (ey + eh / 2.0) - (ly + lh / 2.0);
|
||||
assert!(dcx.abs() <= 2.0 && dcy.abs() <= 2.0,
|
||||
"{eff}: centres differ by ({dcx},{dcy}) -- not concentric");
|
||||
// uniform outset, NOT a uniform scale?
|
||||
let (ox, oy) = ((ew - lw) / 2.0, (eh - lh) / 2.0);
|
||||
assert!((ox - oy).abs() <= 2.0,
|
||||
"{eff}: outset ({ox},{oy}) is not uniform");
|
||||
assert!(ox >= 8.0 && ox <= 12.0, "{eff}: outset {ox} px outside 8..12");
|
||||
// and the scale reading it displaces
|
||||
let (sx, sy) = (ew / lw, eh / lh);
|
||||
assert!((sx - sy).abs() > 0.05,
|
||||
"{eff}: scales {sx:.3}/{sy:.3} ARE uniform -- 'scaled larger' would stand");
|
||||
println!("{eff:<26} outset {ox:.0}x{oy:.0} px, centre off by \
|
||||
({dcx:.1},{dcy:.1}), scale {sx:.3}/{sy:.3} (NOT uniform)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
use sylpheed_formats::{pak, ui_layout, t8ad};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap(); let i:usize=a.next().unwrap().parse().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
let by=ar.read(&ar.entries()[i]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
let mut v:Vec<(String,u32,u32)>=b.sprites.iter().filter_map(|(n,&(o,s))|{
|
||||
let im=t8ad::parse(&by[o..o+s])?; Some((n.clone(),im.width,im.height))}).collect();
|
||||
v.sort();
|
||||
for (n,w,h) in v { println!(" {w:>5} x {h:<5} {n}"); }
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let ar=pak::PakArchive::open(std::env::args().nth(1).unwrap()).unwrap();
|
||||
let b=ui_layout::parse_build(&ar.read(&ar.entries()[4]).unwrap()).unwrap();
|
||||
for el in b.elements.iter().filter(|e| e.name.contains("ptloop")||e.name.contains("ptbtn")) {
|
||||
println!("{:<18} sprite={:?} has_leaf={}", el.name, el.sprite,
|
||||
b.records.contains_key(&el.name));
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
//! Do `GP_TITLE` records that declare a cycle while sitting at t = 0 actually move?
|
||||
//!
|
||||
//! A static record still declares a cycle length, so a nonzero `+0x08` against a
|
||||
//! largest keyframe time of 0 is a real disagreement. `sylpheed-port` turned that
|
||||
//! into a check on the screens they ship: 20 such records in GP_TITLE, and none
|
||||
//! with any element carrying more than one pose — so the declared cycle is
|
||||
//! visually inert and holding them still is correct.
|
||||
//!
|
||||
//! This re-derives it. If any record had a multi-pose element, the port would be
|
||||
//! holding something the disc says animates.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example static_cycle_inert
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
let (mut total, mut static_cycle, mut multipose) = (0usize, 0usize, 0usize);
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for (name, (off, size)) in &b.records {
|
||||
let rec = &by[*off..(*off + *size).min(by.len())];
|
||||
if rec.len() < 0x10 || &rec[0..4] != b"RATC" {
|
||||
continue;
|
||||
}
|
||||
let Some(leaf) = ui_layout::parse_build(rec) else { continue };
|
||||
total += 1;
|
||||
let times: Vec<u32> = leaf
|
||||
.elements
|
||||
.iter()
|
||||
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.collect();
|
||||
let cycle = ui_layout::loop_length_units(rec).unwrap_or(0);
|
||||
if times.is_empty() || times.iter().max() != Some(&0) || cycle == 0 {
|
||||
continue;
|
||||
}
|
||||
static_cycle += 1;
|
||||
let worst = leaf.elements.iter().map(|el| el.keyframes.len()).max().unwrap_or(0);
|
||||
if worst > 1 {
|
||||
multipose += 1;
|
||||
println!(" 🔴 {name} declares {cycle} units and has an element with {worst} poses");
|
||||
} else if name.starts_with("ptbtn1") {
|
||||
println!(" {name:16} declares {cycle} units, max poses/element {worst}");
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\nnested records in GP_TITLE : {total}");
|
||||
println!("declaring a cycle with every pose at t == 0 : {static_cycle}");
|
||||
println!("...of those, any element with MORE THAN ONE pose : {multipose}");
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
//! Are the records with `max t == 0` UNTIMED, or timed with every pose at 0?
|
||||
//!
|
||||
//! I wrote that 1 530 records are "questions never asked" — no timed keyframe, so
|
||||
//! `max t` is 0 by absence. `sylpheed-port` now says zero records on this disc are
|
||||
//! like that: all 1 530 are timed, every pose at t = 0, so the question is
|
||||
//! well-formed and "not exact" is a real answer. This checks it.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example static_record_census
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<_> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
|
||||
.collect();
|
||||
paks.sort();
|
||||
let (mut untimed, mut all_zero, mut nonzero) = (0usize, 0usize, 0usize);
|
||||
for p in &paks {
|
||||
let Ok(ar) = PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for (_, (off, size)) in &b.records {
|
||||
let rec = &by[*off..(*off + *size).min(by.len())];
|
||||
if rec.len() < 0x10 || &rec[0..4] != b"RATC" {
|
||||
continue;
|
||||
}
|
||||
let Some(leaf) = ui_layout::parse_build(rec) else { continue };
|
||||
let times: Vec<u32> = leaf
|
||||
.elements
|
||||
.iter()
|
||||
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.collect();
|
||||
if times.is_empty() {
|
||||
untimed += 1;
|
||||
} else if times.iter().max() == Some(&0) {
|
||||
all_zero += 1;
|
||||
} else {
|
||||
nonzero += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("nested RATC records that parse: {}", untimed + all_zero + nonzero);
|
||||
println!(" NO timed keyframe at all : {untimed}");
|
||||
println!(" timed, every pose at t == 0 : {all_zero}");
|
||||
println!(" timed, largest t > 0 : {nonzero}");
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//! The sweep strips' pose over their loop, straight off the disc — position and
|
||||
//! alpha per keyframe of the nested `ptloop01`/`ptloop02` leaf records.
|
||||
//!
|
||||
//! `sylpheed-port` asks for "the sweep strips' vertex alpha as a function of
|
||||
//! sweep position". The oracle gives that as scattered samples: three sessions,
|
||||
//! ten frames, each one (x, alpha) at whatever phase the capture caught. If the
|
||||
//! ramp is on the DISC, those samples become a check on a decode instead of the
|
||||
//! whole answer.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example sweep_leaf_ramp -- GP_TITLE 5 6 4
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
let pak = argv.iter().find(|a| a.parse::<usize>().is_err())
|
||||
.cloned().unwrap_or_else(|| "GP_TITLE".to_string());
|
||||
let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak");
|
||||
let builds: Vec<usize> = argv.iter().filter_map(|a| a.parse().ok()).collect();
|
||||
for e in if builds.is_empty() { vec![5usize, 6, 4] } else { builds } {
|
||||
let Ok(by) = ar.read(&ar.entries()[e]) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for name in ["ptloop01.rat", "ptloop02.rat"] {
|
||||
let Some(&(lo, ls)) = b.records.get(name) else { continue };
|
||||
let leaf_bytes = &by[lo..(lo + ls).min(by.len())];
|
||||
println!("=== {pak} entry {e} — {name} (leaf {ls} bytes) ===");
|
||||
if let Some(loop_units) = ui_layout::loop_length_units(leaf_bytes) {
|
||||
println!(" declared loop length: {loop_units} units");
|
||||
}
|
||||
match ui_layout::parse_build(leaf_bytes) {
|
||||
Some(lb) => {
|
||||
for el in &lb.elements {
|
||||
println!(" {} — {} keyframes", el.name, el.keyframes.len());
|
||||
for (i, k) in el.keyframes.iter().enumerate() {
|
||||
println!(" kf{i:<2} t={:<5} x={:<6} y={:<6} sx={:<4} sy={:<4} rot={:<5} fade={:08X} (alpha {:3})",
|
||||
k.time.map(|t| t.to_string()).unwrap_or_else(|| "-".into()),
|
||||
k.x, k.y, k.scale_x, k.scale_y, k.rotation_deg,
|
||||
k.fade, k.fade >> 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => println!(" (leaf did not parse as a build)"),
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//! Does a `.t32` sprite's own T8aD header carry a per-sprite blend/alpha mode?
|
||||
//!
|
||||
//! The declaration entry does not: `ptframe1`/`ptframe2` are kind 0, identical to
|
||||
//! every other plain sprite on the menu. The remaining place a mode could live is
|
||||
//! the sprite's own T8aD child. ⚠️ `REFUTED.md` already kills one reading of it —
|
||||
//! "`T8aD +0x04` bit `0x02` selects an additive blend" — so this is not that
|
||||
//! claim; it asks whether ANY header word separates the two frames from the
|
||||
//! sprites the port measures as ordinary.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example t8ad_header_compare
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
|
||||
let by = ar.read(&ar.entries()[5]).expect("entry 5");
|
||||
let b = ui_layout::parse_build(&by).expect("build");
|
||||
let mut names: Vec<&String> = b.sprites.keys().collect();
|
||||
names.sort();
|
||||
println!("{:<20} {:>8} first 12 header words", "sprite", "size");
|
||||
let mut rows: Vec<(String, Vec<u32>)> = Vec::new();
|
||||
for n in names {
|
||||
let (off, size) = b.sprites[n];
|
||||
let s = &by[off..(off + size).min(by.len())];
|
||||
if s.len() < 48 { continue }
|
||||
let ws: Vec<u32> = (0..12)
|
||||
.map(|k| u32::from_be_bytes([s[k*4], s[k*4+1], s[k*4+2], s[k*4+3]]))
|
||||
.collect();
|
||||
let mark = if n.starts_with("ptframe") { " <- FRAME" } else { "" };
|
||||
println!("{n:<20} {size:>8} {}{mark}",
|
||||
ws.iter().map(|v| format!("{v:08X}")).collect::<Vec<_>>().join(" "));
|
||||
rows.push((n.clone(), ws));
|
||||
}
|
||||
// which words take a value the two frames share and nobody else does?
|
||||
let fr: Vec<&(String, Vec<u32>)> = rows.iter().filter(|(n, _)| n.starts_with("ptframe")).collect();
|
||||
if fr.len() == 2 {
|
||||
println!("\nwords where BOTH frames agree and no other sprite has that value:");
|
||||
let mut any = false;
|
||||
for w in 0..12 {
|
||||
let a = fr[0].1[w]; let bb = fr[1].1[w];
|
||||
if a != bb { continue }
|
||||
if rows.iter().any(|(n, v)| !n.starts_with("ptframe") && v[w] == a) { continue }
|
||||
println!(" word {w} (+0x{:02X}) = {a:08X}", w * 4); any = true;
|
||||
}
|
||||
if !any { println!(" NONE"); }
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
//! Is T8aD `+0x08` a per-sprite MODE, or a texture FORMAT word?
|
||||
//!
|
||||
//! On the main menu the two frames share `+0x08 = 0x8050` and no other sprite has
|
||||
//! it — a candidate for the blend/alpha mode `sylpheed-port` asked for. Before
|
||||
//! offering it, refute it: if 0x8050 is common disc-wide on ordinary sprites, it
|
||||
//! is not frame-specific and not a mode.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example t8ad_word8_census
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<_> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
|
||||
.collect();
|
||||
paks.sort();
|
||||
let mut hist: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
let mut frames_like: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
let mut examples: BTreeMap<u32, Vec<String>> = BTreeMap::new();
|
||||
for p in &paks {
|
||||
let Ok(ar) = PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for (n, (off, size)) in &b.sprites {
|
||||
let s = &by[*off..(*off + *size).min(by.len())];
|
||||
if s.len() < 48 { continue }
|
||||
let w = u32::from_be_bytes([s[8], s[9], s[10], s[11]]);
|
||||
*hist.entry(w).or_default() += 1;
|
||||
if n.contains("frame") { *frames_like.entry(w).or_default() += 1 }
|
||||
let ex = examples.entry(w).or_default();
|
||||
if ex.len() < 3 && !ex.contains(n) { ex.push(n.clone()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
let total: usize = hist.values().sum();
|
||||
println!("{total} sprites disc-wide; distinct +0x08 values: {}\n", hist.len());
|
||||
println!("{:>10} {:>8} {:>10} examples", "value", "count", "of which 'frame'");
|
||||
for (v, c) in hist.iter().filter(|(_, c)| **c >= 20) {
|
||||
println!("{:>#10x} {c:>8} {:>10} {}", v, frames_like.get(v).copied().unwrap_or(0),
|
||||
examples[v].join(", "));
|
||||
}
|
||||
println!("\n0x8050 specifically: {} sprites, {} of them named *frame*",
|
||||
hist.get(&0x8050).copied().unwrap_or(0),
|
||||
frames_like.get(&0x8050).copied().unwrap_or(0));
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
//! What does the unknown paint-order TIE-BREAK actually cost, in pixels?
|
||||
//!
|
||||
//! `ui-paint-order-derived-check.md` bounds *where* a wrong tie-break could
|
||||
//! show — 24 overlapping tied pairs across `GP_TITLE` — and says outright that
|
||||
//! nobody has measured how many of them change a pixel. Overlap is an upper
|
||||
//! bound: two elements can overlap and still composite identically in either
|
||||
//! order, if either is transparent where they meet.
|
||||
//!
|
||||
//! This renders each screen twice — once in the order `compose` derives, once
|
||||
//! with one tied pair swapped — and counts the pixels that differ. Same-key
|
||||
//! elements are contiguous in the derived order (a stable sort by `(key, i)`),
|
||||
//! so swapping two of them paints nothing else in between: the diff is the
|
||||
//! tie-break's cost and nothing else.
|
||||
//!
|
||||
//! Every entry also runs a CONTROL: a swap of two OVERLAPPING elements with
|
||||
//! DIFFERENT keys, i.e. a pair whose order the game is known to care about. If
|
||||
//! the control diff is zero the instrument cannot see a reorder on this screen
|
||||
//! and its zeros mean nothing.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example tie_break_pixel_cost -- <GP_TITLE.pak>
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
use ui_layout::{ComposeOptions, UiBuild};
|
||||
|
||||
/// Pixels that differ, and the largest per-channel difference.
|
||||
fn diff(a: &[u8], b: &[u8]) -> (usize, u8) {
|
||||
let (mut n, mut worst) = (0usize, 0u8);
|
||||
for (pa, pb) in a.chunks_exact(4).zip(b.chunks_exact(4)) {
|
||||
if pa != pb {
|
||||
n += 1;
|
||||
for k in 0..4 {
|
||||
worst = worst.max(pa[k].abs_diff(pb[k]));
|
||||
}
|
||||
}
|
||||
}
|
||||
(n, worst)
|
||||
}
|
||||
|
||||
/// Where does element `ei` actually put ink? Render with it and without it;
|
||||
/// the pixels that move are the ones it paints. This is what turns a bare
|
||||
/// "0 px differ" into an explained one: bounding boxes can overlap while the
|
||||
/// sprites inside them never touch the same pixel.
|
||||
fn ink_mask(
|
||||
build: &UiBuild,
|
||||
bundle: &[u8],
|
||||
opts: ComposeOptions,
|
||||
order: &[usize],
|
||||
base: &[u8],
|
||||
ei: usize,
|
||||
) -> Vec<bool> {
|
||||
let n_el = build.elements.iter().map(|e| e.index).max().unwrap_or(0) + 1;
|
||||
let mut vis = vec![true; n_el.max(build.elements.len())];
|
||||
vis[build.elements[ei].index] = false;
|
||||
let without = ui_layout::compose_with_order(build, bundle, opts, Some(&vis), Some(order));
|
||||
base.chunks_exact(4)
|
||||
.zip(without.rgba.chunks_exact(4))
|
||||
.map(|(x, y)| x != y)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn swapped(order: &[usize], a: usize, b: usize) -> Vec<usize> {
|
||||
let mut o = order.to_vec();
|
||||
let (pa, pb) = (
|
||||
o.iter().position(|&e| e == a).unwrap(),
|
||||
o.iter().position(|&e| e == b).unwrap(),
|
||||
);
|
||||
o.swap(pa, pb);
|
||||
o
|
||||
}
|
||||
|
||||
/// The element's on-screen rect, the same approximation the tie census uses:
|
||||
/// the declared pivot doubled, placed at the keyframe. `at` selects the pose —
|
||||
/// `None` is `rest()`, which is where the original census was computed.
|
||||
///
|
||||
/// 🔴 An element that is TRANSPARENT at the chosen pose gets no rect at all. A
|
||||
/// tie involving something invisible cannot cost a pixel, and counting it as an
|
||||
/// overlap is what made the original census an upper bound rather than a cost.
|
||||
fn rect(e: &ui_layout::Element, at: Option<u32>) -> Option<(i32, i32, i32, i32)> {
|
||||
let kf = match at {
|
||||
Some(t) => e.pose_at(t)?,
|
||||
None => e.rest()?.clone(),
|
||||
};
|
||||
if kf.fade >> 24 == 0 || kf.scale_x == 0 || kf.scale_y == 0 {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = ((e.pivot_x * 2) as i32, (e.pivot_y * 2) as i32);
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((kf.x, kf.y, w, h))
|
||||
}
|
||||
|
||||
fn overlaps(a: &ui_layout::Element, b: &ui_layout::Element, at: Option<u32>) -> bool {
|
||||
let (Some(ra), Some(rb)) = (rect(a, at), rect(b, at)) else {
|
||||
return false;
|
||||
};
|
||||
(ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0) > 0
|
||||
&& (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1) > 0
|
||||
}
|
||||
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
opts: ComposeOptions,
|
||||
}
|
||||
|
||||
fn cases() -> Vec<Case> {
|
||||
vec![
|
||||
Case {
|
||||
name: "default (what `screen render` draws)",
|
||||
opts: ComposeOptions {
|
||||
backdrop: [0, 0, 0, 255],
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
Case {
|
||||
name: "everything on (focus+animated+primitives)",
|
||||
opts: ComposeOptions {
|
||||
include_focus: true,
|
||||
include_animated: true,
|
||||
include_primitives: true,
|
||||
backdrop: [0, 0, 0, 255],
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
// 🔴 The two cases above pose at `rest()`, which is each element's last
|
||||
// hold picked independently — so they draw transients that the settled
|
||||
// screen does not have (`docs/re/structures/ui-settle-time.md`). A tie
|
||||
// between two elements that are transparent at the settle time cannot
|
||||
// cost a pixel on the screen the player sees, however much their rects
|
||||
// overlap at rest. `at` is filled in per entry.
|
||||
Case {
|
||||
name: "AT THE SETTLE TIME (what the player sees)",
|
||||
opts: ComposeOptions {
|
||||
backdrop: [0, 0, 0, 255],
|
||||
at: Some(0), // replaced per entry
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn tied_overlapping_pairs(build: &UiBuild, bytes: &[u8], at: Option<u32>) -> Vec<(usize, usize, u32)> {
|
||||
let keys: Vec<u32> = build
|
||||
.elements
|
||||
.iter()
|
||||
.map(|e| ui_layout::sprite_layer_key(build, bytes, e).unwrap_or(u32::MAX))
|
||||
.collect();
|
||||
let mut out = Vec::new();
|
||||
for a in 0..keys.len() {
|
||||
for b in (a + 1)..keys.len() {
|
||||
if keys[a] != keys[b] || keys[a] == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
if overlaps(&build.elements[a], &build.elements[b], at) {
|
||||
out.push((a, b, keys[a]));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A pair the game's own order DOES separate: overlapping, different keys.
|
||||
/// Used as the control — swapping it must move pixels.
|
||||
fn control_pair(build: &UiBuild, bytes: &[u8], at: Option<u32>) -> Option<(usize, usize)> {
|
||||
let keys: Vec<u32> = build
|
||||
.elements
|
||||
.iter()
|
||||
.map(|e| ui_layout::sprite_layer_key(build, bytes, e).unwrap_or(u32::MAX))
|
||||
.collect();
|
||||
let mut best: Option<(i64, usize, usize)> = None;
|
||||
for a in 0..keys.len() {
|
||||
for b in (a + 1)..keys.len() {
|
||||
if keys[a] == keys[b] || keys[a] == u32::MAX || keys[b] == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
if !overlaps(&build.elements[a], &build.elements[b], at) {
|
||||
continue;
|
||||
}
|
||||
let (ra, rb) = (rect(&build.elements[a], at)?, rect(&build.elements[b], at)?);
|
||||
let ox = ((ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0)) as i64;
|
||||
let oy = ((ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1)) as i64;
|
||||
let area = ox * oy;
|
||||
if best.map_or(true, |(x, _, _)| area > x) {
|
||||
best = Some((area, a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, a, b)| (a, b))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args()
|
||||
.nth(1)
|
||||
.expect("usage: tie_break_pixel_cost <pak>");
|
||||
let ar = pak::PakArchive::open(&path).expect("open pak");
|
||||
let entries: Vec<_> = ar.entries().to_vec();
|
||||
println!("# tie-break pixel cost — {path}\n");
|
||||
let mut totals = (0usize, 0usize, 0usize); // pairs, changed-a-pixel, controls-dead
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(build) = ui_layout::parse_build(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
// The census pairs are still computed at rest, so the report can say
|
||||
// how many of THOSE survive posing at the settle time.
|
||||
let pairs_at_rest = tied_overlapping_pairs(&build, &bytes, None);
|
||||
if pairs_at_rest.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let settle = build.settle_time();
|
||||
println!(
|
||||
"entry {i:2} {} elements {} overlapping tied pair(s) at rest{}",
|
||||
build.elements.len(),
|
||||
pairs_at_rest.len(),
|
||||
match (settle, build.settle_window()) {
|
||||
(Some(t), Some((lo, hi))) => format!(" settle t={t} (window {} units)", hi - lo),
|
||||
_ => " NO SETTLE WINDOW".to_string(),
|
||||
}
|
||||
);
|
||||
let derived = ui_layout::derived_paint_order(&build, &bytes);
|
||||
for mut c in cases() {
|
||||
// The settle case is a no-op on a bundle that never settles.
|
||||
if c.opts.at.is_some() {
|
||||
match settle {
|
||||
Some(t) => c.opts.at = Some(t),
|
||||
None => {
|
||||
println!(" [{}] SKIPPED: no settle window", c.name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let pairs = tied_overlapping_pairs(&build, &bytes, c.opts.at);
|
||||
if pairs.len() != pairs_at_rest.len() {
|
||||
println!(
|
||||
" [{}] 🔴 {} of the {} tied pairs are GONE at this pose (an element is \
|
||||
transparent or collapsed there) — they cannot cost a pixel",
|
||||
c.name,
|
||||
pairs_at_rest.len() - pairs.len(),
|
||||
pairs_at_rest.len()
|
||||
);
|
||||
}
|
||||
let base = ui_layout::compose_with_order(&build, &bytes, c.opts, None, Some(&derived));
|
||||
let drawn: std::collections::HashSet<usize> = base.drawn.iter().copied().collect();
|
||||
// Control first. An instrument that cannot see a reorder it is
|
||||
// supposed to see makes every zero below meaningless.
|
||||
let ctrl = match control_pair(&build, &bytes, c.opts.at) {
|
||||
Some((a, b)) if drawn.contains(&a) && drawn.contains(&b) => {
|
||||
let alt = ui_layout::compose_with_order(
|
||||
&build,
|
||||
&bytes,
|
||||
c.opts,
|
||||
None,
|
||||
Some(&swapped(&derived, a, b)),
|
||||
);
|
||||
let (n, w) = diff(&base.rgba, &alt.rgba);
|
||||
Some((a, b, n, w))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match ctrl {
|
||||
Some((a, b, n, w)) if n > 0 => println!(
|
||||
" [{}] CONTROL ok: swapping [{a}] {} x [{b}] {} moves {n} px (max Δ {w})",
|
||||
c.name, build.elements[a].name, build.elements[b].name
|
||||
),
|
||||
Some((a, b, _, _)) => {
|
||||
totals.2 += 1;
|
||||
println!(
|
||||
" [{}] CONTROL DEAD: swapping [{a}] {} x [{b}] {} changes NOTHING — \
|
||||
zeros below are uninterpretable",
|
||||
c.name, build.elements[a].name, build.elements[b].name
|
||||
)
|
||||
}
|
||||
None => {
|
||||
totals.2 += 1;
|
||||
println!(
|
||||
" [{}] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn",
|
||||
c.name
|
||||
)
|
||||
}
|
||||
}
|
||||
for &(a, b, key) in &pairs {
|
||||
let both_drawn = drawn.contains(&a) && drawn.contains(&b);
|
||||
if !both_drawn {
|
||||
println!(
|
||||
" [{}] [{a}] {} x [{b}] {} (key {key}): NOT BOTH DRAWN — unreachable here",
|
||||
c.name, build.elements[a].name, build.elements[b].name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let alt = ui_layout::compose_with_order(
|
||||
&build,
|
||||
&bytes,
|
||||
c.opts,
|
||||
None,
|
||||
Some(&swapped(&derived, a, b)),
|
||||
);
|
||||
let (n, w) = diff(&base.rgba, &alt.rgba);
|
||||
let total = (base.width as usize) * (base.height as usize);
|
||||
// Explain the number: how many pixels do the two BOTH paint on?
|
||||
// A zero with a large shared-ink count is a real "order does
|
||||
// not matter here"; a zero with no shared ink means the
|
||||
// bounding boxes overlapped and the sprites did not.
|
||||
let ma = ink_mask(&build, &bytes, c.opts, &derived, &base.rgba, a);
|
||||
let mb = ink_mask(&build, &bytes, c.opts, &derived, &base.rgba, b);
|
||||
let shared = ma.iter().zip(&mb).filter(|(x, y)| **x && **y).count();
|
||||
let (ia, ib) = (
|
||||
ma.iter().filter(|x| **x).count(),
|
||||
mb.iter().filter(|x| **x).count(),
|
||||
);
|
||||
println!(
|
||||
" [{}] [{a}] {} x [{b}] {} (key {key}): {n} px differ ({:.4}% of frame), \
|
||||
max Δ {w} | ink {ia} / {ib} px, shared {shared} px",
|
||||
c.name,
|
||||
build.elements[a].name,
|
||||
build.elements[b].name,
|
||||
100.0 * n as f64 / total as f64
|
||||
);
|
||||
if c.name.starts_with("default") {
|
||||
totals.0 += 1;
|
||||
if n > 0 {
|
||||
totals.1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
println!(
|
||||
"default-options summary: {} of {} overlapping tied pairs change at least one pixel; \
|
||||
{} dead/unavailable controls",
|
||||
totals.1, totals.0, totals.2
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//! How many tied pairs can cost a pixel, as a function of TIME?
|
||||
//!
|
||||
//! `tie_break_pixel_cost` answers "at one pose". That leaves the answer looking
|
||||
//! like it might be a knife-edge: pick a different instant and the count could
|
||||
//! jump. This sweeps every keyframe time in the bundle and reports, per entry,
|
||||
//! how many same-key pairs are simultaneously **opaque, non-collapsed and
|
||||
//! overlapping** — the pairs whose order could possibly matter at that instant.
|
||||
//!
|
||||
//! The instrument's control is built in: the count at t=0 (nothing has faded in)
|
||||
//! and the count at rest must bracket it, and an entry whose count is flat at
|
||||
//! zero for the whole sweep would be suspicious rather than reassuring — so the
|
||||
//! peak is printed too.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
use ui_layout::UiBuild;
|
||||
|
||||
fn rect(e: &ui_layout::Element, t: u32) -> Option<(i32, i32, i32, i32)> {
|
||||
let kf = e.pose_at(t)?;
|
||||
if kf.fade >> 24 == 0 || kf.scale_x == 0 || kf.scale_y == 0 {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = ((e.pivot_x * 2) as i32, (e.pivot_y * 2) as i32);
|
||||
if w == 0 || h == 0 { return None; }
|
||||
Some((kf.x, kf.y, w, h))
|
||||
}
|
||||
|
||||
fn live_pairs(b: &UiBuild, bytes: &[u8], keys: &[u32], t: u32) -> usize {
|
||||
let mut n = 0;
|
||||
for a in 0..keys.len() {
|
||||
for c in (a + 1)..keys.len() {
|
||||
if keys[a] != keys[c] || keys[a] == u32::MAX { continue }
|
||||
let (Some(ra), Some(rb)) = (rect(&b.elements[a], t), rect(&b.elements[c], t)) else { continue };
|
||||
if (ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0) > 0
|
||||
&& (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1) > 0 { n += 1 }
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("usage: tie_cost_over_time <pak>");
|
||||
let ar = pak::PakArchive::open(&path).expect("open pak");
|
||||
println!("# tied pairs that could cost a pixel, over time — {path}\n");
|
||||
for (i, e) in ar.entries().to_vec().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let keys: Vec<u32> = b.elements.iter()
|
||||
.map(|el| ui_layout::sprite_layer_key(&b, &by, el).unwrap_or(u32::MAX)).collect();
|
||||
let mut ts: Vec<u32> = b.elements.iter()
|
||||
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)).collect();
|
||||
ts.sort_unstable(); ts.dedup();
|
||||
if ts.len() < 2 { continue }
|
||||
let last = *ts.last().unwrap();
|
||||
// sample every keyframe time AND every midpoint between them
|
||||
let mut samples: Vec<u32> = ts.clone();
|
||||
for w in ts.windows(2) { samples.push(w[0] + (w[1] - w[0]) / 2); }
|
||||
samples.sort_unstable(); samples.dedup();
|
||||
let counts: Vec<(u32, usize)> =
|
||||
samples.iter().map(|&t| (t, live_pairs(&b, &by, &keys, t))).collect();
|
||||
let peak = counts.iter().map(|&(_, n)| n).max().unwrap_or(0);
|
||||
if peak == 0 { continue }
|
||||
let Some((lo, hi)) = b.settle_window() else { continue };
|
||||
let st = b.settle_time().unwrap();
|
||||
let at_settle = live_pairs(&b, &by, &keys, st);
|
||||
// the whole plateau, not just its midpoint
|
||||
let plateau: Vec<usize> =
|
||||
(lo..=hi).step_by(((hi - lo).max(1) / 8).max(1) as usize)
|
||||
.map(|t| live_pairs(&b, &by, &keys, t)).collect();
|
||||
let pmax = plateau.iter().copied().max().unwrap_or(0);
|
||||
println!("entry {i:2} peak {peak} live pair(s) over t=0..{last} \
|
||||
settle window [{lo},{hi}] at t={st}: {at_settle} ACROSS THE WHOLE WINDOW: max {pmax}");
|
||||
let busy: Vec<String> = counts.iter().filter(|&&(_, n)| n > 0)
|
||||
.map(|&(t, n)| format!("t{t}:{n}")).collect();
|
||||
if busy.len() <= 24 { println!(" live only at {}", busy.join(" ")); }
|
||||
else { println!(" live at {} of {} sampled instants", busy.len(), counts.len()); }
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
//! Is raising the start filter's 1.5 MB cap safe, disc-wide?
|
||||
//!
|
||||
//! `voice_region_fix_test.rs` shows that for `ADV` the predecessor start recovers
|
||||
//! the decoder's own three byte_sizes exactly. But the cap exists to protect a
|
||||
//! case: the code says *"only within one bank (~1.5 MB), else this is the first cue
|
||||
//! in its block and the audio starts at the anchor itself"*. Raising it blindly
|
||||
//! could pull a **previous asset's** streams into the region.
|
||||
//!
|
||||
//! So compare, per movie: the chunk list the resolver gives today against the one
|
||||
//! the predecessor start gives. A safe change makes the FIRST chunk bigger and
|
||||
//! leaves the rest identical. An unsafe one adds leading chunks.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_region_cap_sweep
|
||||
|
||||
use sylpheed_formats::hash::name_hash;
|
||||
use sylpheed_formats::media::{DirectorySource, DiscSource};
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
use sylpheed_formats::{movie_manifest, movie_voice};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
let code = VoiceLang::English.code_pub();
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let manifest = tpak.entries().iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("manifest");
|
||||
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
|
||||
let registry = tpak.entries().iter().find_map(|e| {
|
||||
tpak.read(e).ok().filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
|
||||
}).expect("registry");
|
||||
let ids = movie_voice::registry_voice_ids(®istry);
|
||||
let stoc = src.read_file("dat/sound.pak").expect("sound.pak");
|
||||
let entries = PakArchive::parse_toc(&stoc).expect("toc");
|
||||
|
||||
let (mut same, mut grew, mut extra, mut skip) = (0, 0, 0, 0);
|
||||
for m in movie_manifest::parse(&manifest) {
|
||||
let movie = m.movie;
|
||||
let Some(token) = movie_manifest::voice_token(&manifest, &movie) else { skip += 1; continue };
|
||||
let Some(&id) = ids.get(&token) else { skip += 1; continue };
|
||||
let Some(anchor) = ["Movie","etc","Voice"].iter().find_map(|dir| {
|
||||
let h = name_hash(&format!("{code}\\{dir}\\{token}.slb"));
|
||||
entries.binary_search_by_key(&h, |e| e.name_hash).ok().map(|i| entries[i].offset as u64)
|
||||
}) else { skip += 1; continue };
|
||||
let win_start = anchor.saturating_sub(2*1024*1024) & !3;
|
||||
let Ok(window) = src.read_segment_range("dat/sound", win_start, 8*1024*1024) else { skip += 1; continue };
|
||||
let Some(end_local) = movie_voice::find_descriptor(&window, id) else { skip += 1; continue };
|
||||
let end = win_start + end_local as u64;
|
||||
let cand = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
|
||||
.or_else(|| movie_voice::find_descriptor_before(&window, end_local))
|
||||
.map(|o| win_start + o as u64)
|
||||
.filter(|&s| s < end);
|
||||
let today = cand.filter(|&s| end - s < 1_500_000).unwrap_or(anchor);
|
||||
let Some(proposed) = cand else { skip += 1; continue };
|
||||
if today == proposed { same += 1; continue }
|
||||
let sizes = |s: u64| -> Vec<usize> {
|
||||
src.read_segment_range("dat/sound", s, (end - s) as usize)
|
||||
.map(|b| slb::to_xma_riffs(&b).iter().map(|r| r.len() - 60).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let (a, b) = (sizes(today), sizes(proposed));
|
||||
let tail_same = a.len() == b.len() && a.iter().skip(1).eq(b.iter().skip(1));
|
||||
let verdict = if a.len() == b.len() && tail_same && b[0] > a[0] {
|
||||
grew += 1; "first chunk GREW, tail identical"
|
||||
} else if b.len() > a.len() { extra += 1; "EXTRA leading chunks" }
|
||||
else { extra += 1; "changed otherwise" };
|
||||
println!("{movie:10} today {a:?}\n{:10} prop {b:?} {verdict}", "");
|
||||
}
|
||||
println!("\nunchanged {same} fixed-cleanly {grew} would-break {extra} skipped {skip}");
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
//! How many voice regions hold three chunks? A COUNT, stated with its population.
|
||||
//!
|
||||
//! `voice-region-starts-late.md` published "8 of 10 three-chunk regions start
|
||||
//! mid-stream". The port agent counts **25** three-chunk regions. Mine was not a
|
||||
//! count: the audit that produced it was cut short and I read a partial file as a
|
||||
//! complete one — it ends mid-list with no summary line.
|
||||
//!
|
||||
//! This does the cheap half properly. It does not step backwards looking for the
|
||||
//! clip; it resolves each region once and counts its chunks, and it prints the
|
||||
//! population, the coverage and the skips **in the same output** so a truncated run
|
||||
//! cannot be mistaken for a complete one.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_region_chunk_census
|
||||
|
||||
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
|
||||
use sylpheed_formats::movie_manifest;
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let manifest = tpak
|
||||
.entries().iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("manifest");
|
||||
let movies: Vec<String> = movie_manifest::parse(&manifest).into_iter().map(|m| m.movie).collect();
|
||||
let total = movies.len();
|
||||
|
||||
let mut hist: BTreeMap<usize, Vec<String>> = BTreeMap::new();
|
||||
let (mut resolved, mut unresolved, mut unreadable) = (0, 0, 0);
|
||||
for movie in movies {
|
||||
let Some((start, end)) = media::resolve_movie_voice_region(&src, &movie, VoiceLang::English)
|
||||
else { unresolved += 1; continue };
|
||||
let Ok(b) = src.read_segment_range("dat/sound", start, (end - start) as usize)
|
||||
else { unreadable += 1; continue };
|
||||
resolved += 1;
|
||||
hist.entry(slb::to_xma_riffs(&b).len()).or_default().push(movie);
|
||||
}
|
||||
println!("POPULATION: {total} movies in the manifest");
|
||||
println!("COVERAGE: {resolved} resolved and read, {unresolved} unresolved, {unreadable} unreadable");
|
||||
println!(" {} accounted for\n", resolved + unresolved + unreadable);
|
||||
for (n, ms) in &hist {
|
||||
println!(" {n} chunk(s): {:>3} region(s) {}", ms.len(),
|
||||
ms.iter().cloned().collect::<Vec<_>>().join(" "));
|
||||
}
|
||||
println!("\nTHREE-CHUNK REGIONS: {}", hist.get(&3).map(|v| v.len()).unwrap_or(0));
|
||||
println!("--- END OF CENSUS (if this line is missing, the run did not finish) ---");
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
//! What ARE the chunks a movie-voice region decodes to?
|
||||
//!
|
||||
//! The port reports a resolved voice region decoding to **three** chunks — two
|
||||
//! of equal duration each spanning the whole movie, and a leading one that
|
||||
//! "matches nothing" — and notes that this is the same 2+1 signature the BGM
|
||||
//! banks showed before `bank_header_len` attributed the extra to a bank header.
|
||||
//! Two different asset kinds with one signature is worth checking, because if
|
||||
//! the same explanation applies then `bank_header_len` is incomplete, and if it
|
||||
//! does not then the leading chunk is something we are discarding.
|
||||
//!
|
||||
//! `slb.rs`'s own doc comment already predicts the answer and disagrees with
|
||||
//! "drop it": the header signature fires on 28 entries, all music banks, with
|
||||
//! "zero false positives on the 7 993 mid-bank windows, WHERE THE LEADING
|
||||
//! REGION IS REAL". A voice region is a mid-bank window by construction —
|
||||
//! `resolve_movie_voice_region` starts it at the PREDECESSOR cue's trailer.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_region_chunks -- <disc-dir>
|
||||
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| std::env::var("SYLPHEED_DISC").expect("disc dir"));
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
|
||||
// Disc-wide, not the four movies that motivated the question: every movie
|
||||
// the manifest binds a voice to.
|
||||
let movies: Vec<String> = {
|
||||
use sylpheed_formats::movie_manifest;
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let manifest = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("movie manifest");
|
||||
movie_manifest::parse(&manifest)
|
||||
.into_iter()
|
||||
.map(|m| m.movie)
|
||||
.collect()
|
||||
};
|
||||
println!("{} movies in the manifest\n", movies.len());
|
||||
let mut census: std::collections::BTreeMap<usize, usize> = Default::default();
|
||||
let (mut with_header, mut with_leading, mut no_leading) = (0, 0, 0);
|
||||
for movie in movies.iter().map(|s| s.as_str()) {
|
||||
let Some((start, end)) = media::resolve_movie_voice_region(&src, movie, VoiceLang::English)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let len = end - start;
|
||||
let Ok(bytes) = src.read_segment_range("dat/sound", start, len as usize) else {
|
||||
println!("{movie:8} region {start}..{end} unreadable");
|
||||
continue;
|
||||
};
|
||||
// Where does the first RIFF sit? Everything before it is the leading
|
||||
// headerless packet region.
|
||||
let first_riff = bytes
|
||||
.windows(4)
|
||||
.position(|w| w == b"RIFF")
|
||||
.map(|p| p as i64)
|
||||
.unwrap_or(-1);
|
||||
let hdr = slb::bank_header_len(&bytes);
|
||||
let riffs = slb::to_xma_riffs(&bytes);
|
||||
let kind = if first_riff <= 0 {
|
||||
no_leading += 1;
|
||||
"no leading region".to_string()
|
||||
} else if hdr == Some(first_riff as usize) {
|
||||
with_header += 1;
|
||||
format!("BANK HEADER ({first_riff} B = {} packets exactly)", first_riff / 2048)
|
||||
} else {
|
||||
with_leading += 1;
|
||||
*census.entry(first_riff as usize % 2048).or_default() += 1;
|
||||
format!(
|
||||
"leading STREAM ({first_riff} B = {} packets + {} B)",
|
||||
first_riff / 2048,
|
||||
first_riff % 2048
|
||||
)
|
||||
};
|
||||
println!(
|
||||
"{movie:10} {start:12}..{end:12} {len:9} B chunks {} {kind}",
|
||||
riffs.len()
|
||||
);
|
||||
if let Ok(dir) = std::env::var("VOICE_CHUNK_DUMP") {
|
||||
for (i, r) in riffs.iter().enumerate() {
|
||||
let _ = std::fs::write(format!("{dir}/{movie}-chunk{i}.wav"), r);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"\n{with_header} region(s) open with a BANK HEADER (bank_header_len fires)\n\
|
||||
{with_leading} open with a leading STREAM\n{no_leading} start at a RIFF"
|
||||
);
|
||||
println!("leading-stream length mod 2048, i.e. the derived data offset:");
|
||||
for (rem, n) in &census {
|
||||
println!(" {rem:5} B x{n}");
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//! Would keeping the predecessor (instead of falling back to `anchor`) recover
|
||||
//! the streams the running decoder actually decodes?
|
||||
//!
|
||||
//! `voice_region_start_why.rs` shows the failing branch: the start filter
|
||||
//! `end - s < 1_500_000` rejects `ADV`'s predecessor because its span is 3.6 MB,
|
||||
//! so `start` falls back to `anchor` — a TOC offset, not a stream boundary.
|
||||
//!
|
||||
//! This does NOT patch the resolver. It asks the one question that decides whether
|
||||
//! raising that cap is the fix: **from the predecessor, does `to_xma_riffs` return
|
||||
//! the decoder's own byte_sizes?** For `ADV` those are known, so this is a test and
|
||||
//! not a fit.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_region_fix_test
|
||||
|
||||
use sylpheed_formats::media::{DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb;
|
||||
|
||||
const ADV_PRED: u64 = 433_425_776;
|
||||
const ADV_ANCHOR: u64 = 433_930_240;
|
||||
const ADV_END: u64 = 437_044_592;
|
||||
/// What the running decoder reported.
|
||||
const WANT: [usize; 3] = [1_294_336, 1_118_208, 1_171_456];
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
for (label, start) in [("anchor (today)", ADV_ANCHOR), ("predecessor (proposed)", ADV_PRED)] {
|
||||
let bytes = src
|
||||
.read_segment_range("dat/sound", start, (ADV_END - start) as usize)
|
||||
.expect("region");
|
||||
let sizes: Vec<usize> = slb::to_xma_riffs(&bytes).iter().map(|r| r.len() - 60).collect();
|
||||
let hit = sizes.len() == 3 && sizes.iter().zip(WANT.iter()).all(|(a, b)| a == b);
|
||||
println!(
|
||||
"{label:24} start {start} span {:>9} -> {:?}{}",
|
||||
ADV_END - start,
|
||||
sizes,
|
||||
if hit { " <== MATCHES THE DECODER" } else { "" }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
//! Does `resolve_movie_voice_region` start inside the first stream, disc-wide?
|
||||
//!
|
||||
//! Verified on `ADV`: the resolver starts **238 packets (487 424 B) late**, and
|
||||
//! extending the span by exactly that reproduces the running decoder's three
|
||||
//! byte_sizes to the byte. The decoder is the ground truth there, but it exists
|
||||
//! for one movie only — so this asks a structural question instead.
|
||||
//!
|
||||
//! **If the region began at a stream boundary, stepping the start backwards would
|
||||
//! immediately expose the PREVIOUS asset's chunks.** If it began mid-stream, the
|
||||
//! first chunk instead *grows*, packet for packet, until the real boundary. The
|
||||
//! number of packets it grows for is the clip.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_region_start_audit
|
||||
|
||||
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
|
||||
use sylpheed_formats::movie_manifest;
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let manifest = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("manifest");
|
||||
let movies: Vec<String> = movie_manifest::parse(&manifest)
|
||||
.into_iter()
|
||||
.map(|m| m.movie)
|
||||
.collect();
|
||||
|
||||
println!("{:10} {:>8} {:>12} {:>10} {}", "movie", "chunks", "first chunk", "clip pkts", "verdict");
|
||||
let (mut clipped, mut clean, mut skipped) = (0, 0, 0);
|
||||
for movie in movies {
|
||||
let Some((start, end)) = media::resolve_movie_voice_region(&src, &movie, VoiceLang::English)
|
||||
else { skipped += 1; continue };
|
||||
// ONE read of the region plus a lead-in, then slide inside it. Re-reading
|
||||
// several MB per step made this too slow to finish at all.
|
||||
const LEAD: u64 = 600 * 2048;
|
||||
let lead = LEAD.min(start);
|
||||
let buf = match src.read_segment_range("dat/sound", start - lead, (end - (start - lead)) as usize) {
|
||||
Ok(b) => b, Err(_) => { skipped += 1; continue }
|
||||
};
|
||||
let at = |back: usize| -> Vec<Vec<u8>> {
|
||||
let off = lead as usize - back * 2048;
|
||||
slb::to_xma_riffs(&buf[off..])
|
||||
};
|
||||
let base_riffs = at(0);
|
||||
if base_riffs.is_empty() { skipped += 1; continue }
|
||||
let n0 = base_riffs.len();
|
||||
let first0 = base_riffs[0].len() - 60;
|
||||
let mut clip = 0usize;
|
||||
for k in 1..=(lead as usize / 2048) {
|
||||
if at(k).len() != n0 { break }
|
||||
clip = k;
|
||||
}
|
||||
let verdict = if clip == 0 { clean += 1; "starts at a boundary" }
|
||||
else { clipped += 1; "STARTS MID-STREAM" };
|
||||
println!("{movie:10} {n0:>8} {first0:>12} {clip:>10} {verdict}");
|
||||
}
|
||||
println!("\nclipped {clipped} clean {clean} skipped {skipped}");
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
//! WHY does `resolve_movie_voice_region` start inside the first stream?
|
||||
//!
|
||||
//! [`voice-region-starts-late.md`] establishes that it does — 238 packets late for
|
||||
//! `ADV`, 8 of 10 multichannel regions disc-wide — but not why, and a fix guessed
|
||||
//! from one movie would be worse than a documented defect. This reproduces the
|
||||
//! resolver's own steps and prints each candidate, so the failing branch is visible
|
||||
//! rather than inferred.
|
||||
//!
|
||||
//! The suspicion the code itself raises: the start is filtered by
|
||||
//! `end - s < 1_500_000` — "only within one bank" — and `ADV`'s region has to span
|
||||
//! **3.6 MB**. If that filter rejects the real predecessor, `start` silently falls
|
||||
//! back to `anchor`, which is a TOC offset and not a stream boundary at all.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_region_start_why
|
||||
|
||||
use sylpheed_formats::hash::name_hash;
|
||||
use sylpheed_formats::media::{DirectorySource, DiscSource};
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
use sylpheed_formats::slb::VoiceLang;
|
||||
use sylpheed_formats::{movie_manifest, movie_voice};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
let code = VoiceLang::English.code_pub();
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let manifest = tpak
|
||||
.entries().iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("manifest");
|
||||
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
|
||||
let registry = tpak.entries().iter().find_map(|e| {
|
||||
tpak.read(e).ok().filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
|
||||
}).expect("registry");
|
||||
let ids = movie_voice::registry_voice_ids(®istry);
|
||||
let stoc = src.read_file("dat/sound.pak").expect("sound.pak");
|
||||
let entries = PakArchive::parse_toc(&stoc).expect("toc");
|
||||
|
||||
println!("{:10} {:>6} {:>12} {:>12} {:>12} {:>10} {:>9} {}",
|
||||
"movie","id","anchor","pred(id-1)","pred(before)","span","chosen","note");
|
||||
for m in movie_manifest::parse(&manifest) {
|
||||
let movie = m.movie;
|
||||
let Some(token) = movie_manifest::voice_token(&manifest, &movie) else { continue };
|
||||
let Some(&id) = ids.get(&token) else { continue };
|
||||
let Some(anchor) = ["Movie","etc","Voice"].iter().find_map(|dir| {
|
||||
let h = name_hash(&format!("{code}\\{dir}\\{token}.slb"));
|
||||
entries.binary_search_by_key(&h, |e| e.name_hash).ok().map(|i| entries[i].offset as u64)
|
||||
}) else { continue };
|
||||
let win_start = anchor.saturating_sub(2*1024*1024) & !3;
|
||||
let Ok(window) = src.read_segment_range("dat/sound", win_start, 8*1024*1024) else { continue };
|
||||
let Some(end_local) = movie_voice::find_descriptor(&window, id) else { continue };
|
||||
let end = win_start + end_local as u64;
|
||||
let p1 = movie_voice::find_descriptor(&window, id.wrapping_sub(1)).map(|o| win_start + o as u64);
|
||||
let pb = movie_voice::find_descriptor_before(&window, end_local).map(|o| win_start + o as u64);
|
||||
let cand = p1.or(pb);
|
||||
// the resolver's own filter
|
||||
let kept = cand.filter(|&s| s < end && end - s < 1_500_000);
|
||||
let chosen = kept.unwrap_or(anchor);
|
||||
let span = cand.map(|s| end.saturating_sub(s)).unwrap_or(0);
|
||||
let note = match (cand, kept) {
|
||||
(Some(_), None) => "REJECTED by the 1.5 MB filter -> fell back to anchor",
|
||||
(Some(_), Some(_)) => "predecessor kept",
|
||||
(None, _) => "no predecessor found -> anchor",
|
||||
};
|
||||
println!("{movie:10} {id:>6} {anchor:>12} {:>12} {:>12} {span:>10} {:>9} {note}",
|
||||
p1.map(|v| v.to_string()).unwrap_or("-".into()),
|
||||
pb.map(|v| v.to_string()).unwrap_or("-".into()),
|
||||
if chosen == anchor { "anchor" } else { "pred" });
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
//! Who owns the bytes in front of a movie-voice region's first `RIFF`?
|
||||
//!
|
||||
//! [`voice-region-leading-chunk.md`] left one thing open: the leading chunk of
|
||||
//! the 17 stream-opening regions is real XMA audio that **no other movie-voice
|
||||
//! region claims** — but the census only enumerated the 95 movie cues, while the
|
||||
//! same continuous stream also carries the in-mission `VOICE_D_*` lines. The
|
||||
//! leading hypothesis was that the bytes belong to one of those, and the port
|
||||
//! pointed out that the byte-span test already written settles it *without
|
||||
//! anyone listening* if the enumeration is widened.
|
||||
//!
|
||||
//! So this widens it the whole way: rather than resolving cues one at a time
|
||||
//! through the manifest, scan the stream itself for **every** trailer descriptor
|
||||
//! — the `(id: u32be, 0x11, …)` pair whose id repeats at `+0x800`, which
|
||||
//! `movie_voice` documents as the end of a cue's audio. Cue N's audio is
|
||||
//! `[descriptor(N-1) .. descriptor(N)]`, so the full descriptor list IS the
|
||||
//! complete cue partition of the stream, movie and mission alike.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_stream_cue_map -- <disc-dir>
|
||||
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb::VoiceLang;
|
||||
|
||||
const DESC_MARK: u32 = 0x11;
|
||||
const DESC_REPEAT: usize = 0x800;
|
||||
const ID_MAX: u32 = 0x1_0000;
|
||||
|
||||
/// Every trailer descriptor in `buf`, as `(offset, id)`.
|
||||
///
|
||||
/// Same predicate `movie_voice::find_descriptor` uses — the id-repeat at +0x800
|
||||
/// is what makes a false match inside XMA audio ~2^-64.
|
||||
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
|
||||
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
|
||||
let mut out = Vec::new();
|
||||
if buf.len() < DESC_REPEAT + 8 {
|
||||
return out;
|
||||
}
|
||||
let end = buf.len() - (DESC_REPEAT + 4);
|
||||
let mut o = 0;
|
||||
while o <= end {
|
||||
let id = be(o);
|
||||
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
|
||||
out.push((o, id));
|
||||
}
|
||||
o += 4;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| std::env::var("SYLPHEED_DISC").expect("disc dir"));
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
|
||||
// The cue-name -> id registry, so a descriptor id can be named.
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let marker = "eng\\Movie\\VOICE_ADV.slb";
|
||||
let registry = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
tpak.read(e)
|
||||
.ok()
|
||||
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
|
||||
})
|
||||
.expect("voice registry");
|
||||
let ids = sylpheed_formats::movie_voice::registry_voice_ids(®istry);
|
||||
let name_of: std::collections::HashMap<u32, String> =
|
||||
ids.iter().map(|(n, &i)| (i, n.clone())).collect();
|
||||
println!("registry: {} cue names, {} distinct ids", ids.len(), name_of.len());
|
||||
|
||||
// The 17 regions that open with a headerless stream, from the manifest.
|
||||
let movies: Vec<String> = {
|
||||
use sylpheed_formats::movie_manifest;
|
||||
let manifest = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("manifest");
|
||||
movie_manifest::parse(&manifest).into_iter().map(|m| m.movie).collect()
|
||||
};
|
||||
let mut regions = Vec::new();
|
||||
for m in &movies {
|
||||
if let Some((s, e)) = media::resolve_movie_voice_region(&src, m, VoiceLang::English) {
|
||||
regions.push((m.clone(), s, e));
|
||||
}
|
||||
}
|
||||
let lo = regions.iter().map(|r| r.1).min().unwrap();
|
||||
let hi = regions.iter().map(|r| r.2).max().unwrap();
|
||||
// Scan a window covering every region, with margin for cues either side.
|
||||
let win_start = lo.saturating_sub(8 * 1024 * 1024) & !3;
|
||||
let win_len = (hi - win_start + 8 * 1024 * 1024) as usize;
|
||||
println!("scanning dat/sound {win_start}..{} ({:.1} MB)", win_start + win_len as u64,
|
||||
win_len as f64 / 1e6);
|
||||
let buf = src
|
||||
.read_segment_range("dat/sound", win_start, win_len)
|
||||
.expect("stream window");
|
||||
let descs = all_descriptors(&buf);
|
||||
println!("{} trailer descriptors found\n", descs.len());
|
||||
|
||||
let named = descs.iter().filter(|(_, id)| name_of.contains_key(id)).count();
|
||||
println!(" of those, {named} carry an id the registry names, {} do not\n",
|
||||
descs.len() - named);
|
||||
|
||||
// For each stream-opening region, name the cue that OWNS the leading span:
|
||||
// the cue whose [prev_desc .. desc] interval contains it.
|
||||
println!("leading span -> owning cue\n");
|
||||
let mut verdicts: std::collections::BTreeMap<&str, usize> = Default::default();
|
||||
for (m, s, e) in ®ions {
|
||||
let Ok(bytes) = src.read_segment_range("dat/sound", *s, (e - s) as usize) else { continue };
|
||||
let Some(fr) = bytes.windows(4).position(|w| w == b"RIFF") else { continue };
|
||||
if fr == 0 || sylpheed_formats::slb::bank_header_len(&bytes) == Some(fr) {
|
||||
continue; // bank-header case, not ours
|
||||
}
|
||||
let (a, b) = (*s, *s + fr as u64); // the leading span, global offsets
|
||||
// Descriptors bracketing the MIDDLE of the leading span.
|
||||
let mid = (a + b) / 2;
|
||||
let mid_local = (mid - win_start) as usize;
|
||||
let before = descs.iter().rev().find(|(o, _)| (*o as u64) < mid_local as u64);
|
||||
let after = descs.iter().find(|(o, _)| *o >= mid_local);
|
||||
let owner = after.map(|(_, id)| *id);
|
||||
let owner_name = owner
|
||||
.and_then(|id| name_of.get(&id).cloned())
|
||||
.unwrap_or_else(|| owner.map(|i| format!("<unnamed id {i}>")).unwrap_or("<none>".into()));
|
||||
let kind = if owner_name.starts_with("VOICE_D_") {
|
||||
"MISSION line"
|
||||
} else if owner_name.starts_with("VOICE_") {
|
||||
"movie cue"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
*verdicts.entry(kind).or_default() += 1;
|
||||
println!(
|
||||
" {m:8} lead {:8} B bracketed by desc@{:?} .. desc@{:?} owner {owner_name} [{kind}]",
|
||||
b - a,
|
||||
before.map(|(o, i)| (*o as u64 + win_start, *i)),
|
||||
after.map(|(o, i)| (*o as u64 + win_start, *i)),
|
||||
);
|
||||
}
|
||||
println!("\nverdicts: {verdicts:?}");
|
||||
|
||||
// WHY do exactly these 17 open mid-cue? `resolve_movie_voice_region` takes
|
||||
// the predecessor trailer as the region start, but guards it with
|
||||
// `end - start < 1_500_000` and falls back to the .slb TOC anchor when that
|
||||
// fails. If the guard is the cause, then the stream-opening regions are
|
||||
// exactly the cues whose true span exceeds the guard.
|
||||
println!("\ncue span vs the 1.5 MB guard, and what the region actually starts at:\n");
|
||||
let (mut over, mut under, mut over_is_stream, mut under_is_stream) = (0, 0, 0, 0);
|
||||
for (m, s, e) in ®ions {
|
||||
let Ok(bytes) = src.read_segment_range("dat/sound", *s, (e - s) as usize) else { continue };
|
||||
let fr = bytes.windows(4).position(|w| w == b"RIFF");
|
||||
let is_stream = matches!(fr, Some(f) if f > 0
|
||||
&& sylpheed_formats::slb::bank_header_len(&bytes) != Some(f));
|
||||
// The true predecessor trailer for this cue, from the full descriptor list.
|
||||
let end_local = (*e - win_start) as usize;
|
||||
let prev = descs.iter().rev().find(|(o, _)| *o < end_local).map(|(o, _)| *o as u64 + win_start);
|
||||
let Some(prev) = prev else { continue };
|
||||
let span = e - prev;
|
||||
let guarded = span >= 1_500_000;
|
||||
if guarded { over += 1; if is_stream { over_is_stream += 1 } }
|
||||
else { under += 1; if is_stream { under_is_stream += 1 } }
|
||||
if is_stream {
|
||||
println!(
|
||||
" {m:8} true cue span {span:8} B (> guard: {guarded}) region starts at {s}, \
|
||||
true start {prev} -> {} B of the cue's own audio is OUTSIDE the region",
|
||||
s.saturating_sub(prev)
|
||||
);
|
||||
}
|
||||
}
|
||||
println!("\ncues over the 1.5 MB guard: {over}, of which stream-opening: {over_is_stream}");
|
||||
println!("cues under the guard: {under}, of which stream-opening: {under_is_stream}");
|
||||
|
||||
// How many streams is ONE cue stored as? The port measured that a region's
|
||||
// leading chunk is the TAIL of its first full-length chunk, i.e. the cue is
|
||||
// re-presented. Structurally that predicts a fixed number of stream starts
|
||||
// inside a cue's TRUE span [desc(N-1) .. desc(N)] -- which is measurable
|
||||
// from the bytes alone, with no decoder.
|
||||
println!("\nstream starts inside each cue's TRUE span (desc(N-1)..desc(N)):\n");
|
||||
let mut hist: std::collections::BTreeMap<usize, usize> = Default::default();
|
||||
let mut hist_long: std::collections::BTreeMap<usize, usize> = Default::default();
|
||||
for w in descs.windows(2) {
|
||||
let (a, b) = (w[0].0, w[1].0);
|
||||
if b <= a || b - a < 4096 {
|
||||
continue;
|
||||
}
|
||||
let span = &buf[a..b];
|
||||
// A stream start is a RIFF; plus the run before the first one, when it
|
||||
// is not a bank header, is itself a stream.
|
||||
let riffs = span
|
||||
.windows(4)
|
||||
.enumerate()
|
||||
.filter(|(_, w)| *w == b"RIFF")
|
||||
.count();
|
||||
let lead_is_stream = match span.windows(4).position(|w| w == b"RIFF") {
|
||||
Some(f) if f > 0 => sylpheed_formats::slb::bank_header_len(span) != Some(f),
|
||||
_ => false,
|
||||
};
|
||||
let streams = riffs + usize::from(lead_is_stream);
|
||||
*hist.entry(streams).or_default() += 1;
|
||||
if b - a >= 1_500_000 {
|
||||
*hist_long.entry(streams).or_default() += 1;
|
||||
}
|
||||
}
|
||||
println!(" all inter-descriptor spans: {hist:?}");
|
||||
println!(" spans >= 1.5 MB (the long cues): {hist_long:?}");
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
//! Are the three streams of a voice cue in a CONSISTENT size relationship?
|
||||
//!
|
||||
//! The port selected a voice presentation on this argument: `ADV` chunk 1 is
|
||||
//! mono-in-stereo (channel 2 digitally silent) and chunk 2 is dual-mono (both
|
||||
//! channels identical), so chunk 2's extra bytes encode a duplicate channel
|
||||
//! rather than fidelity — which would explain its higher declared
|
||||
//! `PsuedoBytesPerSec` without appealing to encode quality.
|
||||
//!
|
||||
//! That is a claim about the *encoding*, and it makes a structural prediction:
|
||||
//! if stream 3 is always "the same take with its channel duplicated", it should
|
||||
//! sit in a consistent size ratio to stream 2 across every 3-stream cue on the
|
||||
//! disc. If the ratio scatters — or if some third streams are tiny — then the
|
||||
//! observation is about `ADV`, not about the format.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_three_stream_sizes -- <disc>
|
||||
use sylpheed_formats::media::{DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb;
|
||||
|
||||
const DESC_MARK: u32 = 0x11;
|
||||
const DESC_REPEAT: usize = 0x800;
|
||||
const ID_MAX: u32 = 0x1_0000;
|
||||
|
||||
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
|
||||
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
|
||||
let mut out = Vec::new();
|
||||
if buf.len() < DESC_REPEAT + 8 {
|
||||
return out;
|
||||
}
|
||||
let end = buf.len() - (DESC_REPEAT + 4);
|
||||
let mut o = 0;
|
||||
while o <= end {
|
||||
let id = be(o);
|
||||
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
|
||||
out.push((o, id));
|
||||
}
|
||||
o += 4;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The declared `PsuedoBytesPerSec` at `fmt +0x20` of a RIFF chunk, if present.
|
||||
fn declared_rate(riff: &[u8]) -> Option<u32> {
|
||||
if riff.len() < 0x28 || &riff[0..4] != b"RIFF" {
|
||||
return None;
|
||||
}
|
||||
Some(u32::from_le_bytes(riff[0x20..0x24].try_into().ok()?))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| std::env::var("SYLPHEED_DISC").expect("disc"));
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let marker = "eng\\Movie\\VOICE_ADV.slb";
|
||||
let registry = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
tpak.read(e)
|
||||
.ok()
|
||||
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
|
||||
})
|
||||
.expect("registry");
|
||||
let ids = sylpheed_formats::movie_voice::registry_voice_ids(®istry);
|
||||
let name_of: std::collections::HashMap<u32, String> =
|
||||
ids.iter().map(|(n, &i)| (i, n.clone())).collect();
|
||||
|
||||
// Same window the cue map uses.
|
||||
let win_start: u64 = 421_739_888 & !3;
|
||||
let win_len: usize = 116_300_000;
|
||||
let buf = src
|
||||
.read_segment_range("dat/sound", win_start, win_len)
|
||||
.expect("window");
|
||||
let descs = all_descriptors(&buf);
|
||||
|
||||
println!("{:<14} {:>10} {:>10} {:>10} {:>7} {:>9} {:>9}",
|
||||
"cue", "stream1", "stream2", "stream3", "s3/s2", "rate2", "rate3");
|
||||
let mut ratios: Vec<f64> = Vec::new();
|
||||
let mut tiny = 0;
|
||||
for w in descs.windows(2) {
|
||||
let (a, b) = (w[0].0, w[1].0);
|
||||
if b <= a || b - a < 4096 {
|
||||
continue;
|
||||
}
|
||||
let span = &buf[a..b];
|
||||
let riffs = slb::to_xma_riffs(span);
|
||||
if riffs.len() != 3 {
|
||||
continue;
|
||||
}
|
||||
let name = name_of
|
||||
.get(&w[1].1)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("id{}", w[1].1));
|
||||
let (s1, s2, s3) = (riffs[0].len(), riffs[1].len(), riffs[2].len());
|
||||
let r = s3 as f64 / s2 as f64;
|
||||
ratios.push(r);
|
||||
if r < 0.5 {
|
||||
tiny += 1;
|
||||
}
|
||||
println!(
|
||||
"{:<14} {s1:>10} {s2:>10} {s3:>10} {r:>7.4} {:>9} {:>9}",
|
||||
name.trim_start_matches("VOICE_"),
|
||||
declared_rate(&riffs[1]).map(|v| v.to_string()).unwrap_or("-".into()),
|
||||
declared_rate(&riffs[2]).map(|v| v.to_string()).unwrap_or("-".into()),
|
||||
);
|
||||
}
|
||||
ratios.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let n = ratios.len();
|
||||
println!("\n{n} three-stream cues");
|
||||
if n > 0 {
|
||||
let mean = ratios.iter().sum::<f64>() / n as f64;
|
||||
let var = ratios.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n as f64;
|
||||
println!(
|
||||
" stream3/stream2 ratio: min {:.4} median {:.4} max {:.4} mean {:.4} sd {:.4}",
|
||||
ratios[0], ratios[n / 2], ratios[n - 1], mean, var.sqrt()
|
||||
);
|
||||
println!(" cues where stream 3 is less than HALF of stream 2: {tiny}");
|
||||
let near = ratios.iter().filter(|r| (**r - 1.0).abs() < 0.15).count();
|
||||
println!(" cues where stream 3 is within 15% of stream 2: {near} of {n}");
|
||||
}
|
||||
}
|
||||
@@ -97,14 +97,6 @@ pub struct AudioInfo {
|
||||
pub size_bytes: usize,
|
||||
/// 2048-byte XMA packet count, for XMA/raw-XMA streams.
|
||||
pub xma_packets: Option<u32>,
|
||||
/// The stream's **declared** average bytes per second.
|
||||
///
|
||||
/// For XMA1 this is `XMASTREAMFORMAT::PsuedoBytesPerSec`. It is what makes a
|
||||
/// duration available for a codec we cannot decode: `data_bytes / this`
|
||||
/// agreed with an independently decoded duration to **0.01 %** on the two
|
||||
/// movie voices it was checked against
|
||||
/// (`docs/re/structures/voice-region-leading-chunk.md`).
|
||||
pub avg_bytes_per_sec: Option<u32>,
|
||||
}
|
||||
|
||||
impl AudioInfo {
|
||||
@@ -118,7 +110,6 @@ impl AudioInfo {
|
||||
duration_secs: None,
|
||||
size_bytes: size,
|
||||
xma_packets: None,
|
||||
avg_bytes_per_sec: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +181,6 @@ fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
|
||||
|
||||
let mut pos = 12;
|
||||
let (mut tag, mut channels, mut rate, mut bits) = (0u16, 0u16, 0u32, 0u16);
|
||||
let mut avg_bps = 0u32;
|
||||
let mut data_bytes: Option<u64> = None;
|
||||
let mut have_fmt = false;
|
||||
|
||||
@@ -201,37 +191,13 @@ fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
|
||||
match id {
|
||||
b"fmt " if body + 16 <= bytes.len() => {
|
||||
tag = le16(body);
|
||||
// 🔴 XMA1 is NOT a WAVEFORMATEX. Reading it as one is where
|
||||
// `audio info` got "16 channels, 4310 Hz, 2-bit" from the
|
||||
// movie voices: 16 is `wBitsPerSample` read as channels, and
|
||||
// 4310 is `wEncodeOptions` (0x10d6) read as a sample rate.
|
||||
//
|
||||
// XMA1 carries `XMAWAVEFORMAT`, then one `XMASTREAMFORMAT` per
|
||||
// stream (xenia-canary `src/xenia/apu/xma_context.h`,
|
||||
// cross-checked against the disc's own movie-voice headers):
|
||||
//
|
||||
// +0 wFormatTag +2 wBitsPerSample +4 wEncodeOptions
|
||||
// +6 wLargestSkip +8 wNumStreams +10 bLoopCount (u8)
|
||||
// +11 bStreamCount (u8)
|
||||
// +12 PsuedoBytesPerSec +16 SampleRate +20 LoopStart
|
||||
// +24 LoopEnd +28 SubframeData (u8) +29 Channels (u8)
|
||||
// +30 ChannelMask
|
||||
if tag == WAVE_FORMAT_XMA && body + 32 <= bytes.len() {
|
||||
bits = le16(body + 2);
|
||||
avg_bps = le32(body + 12);
|
||||
rate = le32(body + 16);
|
||||
channels = bytes[body + 29] as u16;
|
||||
} else {
|
||||
channels = le16(body + 2);
|
||||
rate = le32(body + 4);
|
||||
bits = le16(body + 14);
|
||||
avg_bps = le32(body + 8);
|
||||
// WAVE_FORMAT_EXTENSIBLE stores the real tag in the GUID's
|
||||
// first two bytes, right after cbSize (+2) → +24 from the
|
||||
// fmt body.
|
||||
if tag == WAVE_FORMAT_EXTENSIBLE && body + 26 <= bytes.len() {
|
||||
tag = le16(body + 24);
|
||||
}
|
||||
channels = le16(body + 2);
|
||||
rate = le32(body + 4);
|
||||
bits = le16(body + 14);
|
||||
// WAVE_FORMAT_EXTENSIBLE stores the real tag in the GUID's first
|
||||
// two bytes, right after cbSize (+2) → +24 from the fmt body.
|
||||
if tag == WAVE_FORMAT_EXTENSIBLE && body + 26 <= bytes.len() {
|
||||
tag = le16(body + 24);
|
||||
}
|
||||
have_fmt = true;
|
||||
}
|
||||
@@ -256,15 +222,6 @@ fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
|
||||
info.channels = Some(channels).filter(|&c| c > 0);
|
||||
info.sample_rate = Some(rate).filter(|&r| r > 0);
|
||||
info.bits_per_sample = Some(bits).filter(|&b| b > 0);
|
||||
info.avg_bytes_per_sec = Some(avg_bps).filter(|&b| b > 0);
|
||||
|
||||
// A declared byte rate gives a duration for a codec we cannot decode. Only
|
||||
// for XMA1, where the field is `PsuedoBytesPerSec` and means exactly this.
|
||||
if codec == AudioCodec::Xma && avg_bps > 0 {
|
||||
if let Some(d) = data_bytes {
|
||||
info.duration_secs = Some(d as f32 / avg_bps as f32);
|
||||
}
|
||||
}
|
||||
|
||||
match codec {
|
||||
AudioCodec::Pcm | AudioCodec::PcmFloat => {
|
||||
@@ -399,58 +356,6 @@ mod tests {
|
||||
assert!((audio.samples[2] - 0.99997).abs() < 1e-3); // 32767/32768
|
||||
}
|
||||
|
||||
/// XMA1 is not a `WAVEFORMATEX`, and reading it as one produced nonsense.
|
||||
///
|
||||
/// The bytes here are the real `fmt ` chunk of `ADV`'s first movie-voice
|
||||
/// presentation, copied off the disc. Read as a `WAVEFORMATEX` it reports
|
||||
/// **16 channels, 4310 Hz, 2-bit** — 16 is `wBitsPerSample`, 4310 is
|
||||
/// `wEncodeOptions` (`0x10d6`). Read as an `XMAWAVEFORMAT` it reports 2
|
||||
/// channels, 48 kHz, 16-bit, 8142 B/s.
|
||||
///
|
||||
/// The duration is the part worth guarding: this crate has no XMA decoder,
|
||||
/// and `data_bytes / PsuedoBytesPerSec` is the only route to one. It agrees
|
||||
/// with an independently decoded 137.324 s to **0.02 %**.
|
||||
#[test]
|
||||
fn xma1_fmt_is_not_a_waveformatex() {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(b"RIFF");
|
||||
v.extend_from_slice(&0u32.to_le_bytes());
|
||||
v.extend_from_slice(b"WAVE");
|
||||
v.extend_from_slice(b"fmt ");
|
||||
v.extend_from_slice(&32u32.to_le_bytes());
|
||||
// XMAWAVEFORMAT, exactly as it appears on the disc.
|
||||
v.extend_from_slice(&[
|
||||
0x65, 0x01, // wFormatTag = 0x0165 (XMA1)
|
||||
0x10, 0x00, // wBitsPerSample = 16
|
||||
0xd6, 0x10, // wEncodeOptions = 0x10d6 <- was misread as the rate
|
||||
0x00, 0x00, // wLargestSkip
|
||||
0x01, 0x00, // wNumStreams
|
||||
0x00, // bLoopCount
|
||||
0x02, // bStreamCount
|
||||
0xce, 0x1f, 0x00, 0x00, // PsuedoBytesPerSec = 8142
|
||||
0x80, 0xbb, 0x00, 0x00, // SampleRate = 48000
|
||||
0x00, 0x00, 0x00, 0x00, // LoopStart
|
||||
0x00, 0x00, 0x00, 0x00, // LoopEnd
|
||||
0x00, // SubframeData
|
||||
0x02, // Channels = 2 <- was read from +2 as 16
|
||||
0x02, 0x00, // ChannelMask
|
||||
]);
|
||||
v.extend_from_slice(b"data");
|
||||
v.extend_from_slice(&1_118_208u32.to_le_bytes());
|
||||
|
||||
let info = AudioInfo::probe(&v);
|
||||
assert_eq!(info.codec, AudioCodec::Xma);
|
||||
assert_eq!(info.channels, Some(2), "channels came from wBitsPerSample");
|
||||
assert_eq!(info.sample_rate, Some(48_000), "rate came from wEncodeOptions");
|
||||
assert_eq!(info.bits_per_sample, Some(16));
|
||||
assert_eq!(info.avg_bytes_per_sec, Some(8142));
|
||||
let d = info.duration_secs.expect("duration from the declared byte rate");
|
||||
assert!(
|
||||
(d - 137.324).abs() < 0.05,
|
||||
"declared-rate duration {d} should match the decoded 137.324 s"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_xma2_riff_reports_metadata_not_decode() {
|
||||
// Minimal RIFF/WAVE with an XMA2 fmt tag.
|
||||
|
||||
@@ -288,29 +288,12 @@ pub fn resolve_movie_voice_region<S: DiscSource + ?Sized>(
|
||||
|
||||
// Start = the predecessor trailer. Prefer the exact `id-1`; where the id
|
||||
// sequence has a gap (VOICE_D_453 → 454) fall back to the nearest trailer
|
||||
// below.
|
||||
//
|
||||
// 🔴 There used to be a second condition here — `end - s < 1_500_000`, "only
|
||||
// within one bank, else this is the first cue in its block and the audio
|
||||
// starts at the anchor itself". **It was wrong, and it silently truncated the
|
||||
// first stream of every region larger than 1.5 MB.** `anchor` is a TOC offset,
|
||||
// not a stream boundary, so the fallback started mid-packet-run: `ADV` began
|
||||
// **238 packets (487 424 B) into its own first stream**, and a consumer then
|
||||
// saw a leading chunk that "matched nothing" and dropped 62 % of a real stream.
|
||||
//
|
||||
// Ground truth is the running decoder, which reports `ADV`'s three contexts as
|
||||
// 1 294 336 / 1 118 208 / 1 171 456 (`--xma_param_probe`). With the cap gone the
|
||||
// region reproduces all three exactly; with it, the first is 806 912.
|
||||
//
|
||||
// Disc-wide over the 95 manifest movies that resolve: **17 regions fixed, 78
|
||||
// unchanged, 0 changed in any other way** — in every one of the 17 the first
|
||||
// chunk grows and the remaining chunks are byte-identical, which is what a
|
||||
// corrected start looks like and what pulling in a neighbouring asset does not.
|
||||
// `docs/re/structures/voice-region-starts-late.md`.
|
||||
// below — but only within one bank (~1.5 MB), else this is the first cue in
|
||||
// its block and the audio starts at the anchor itself.
|
||||
let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
|
||||
.or_else(|| movie_voice::find_descriptor_before(&window, end_local))
|
||||
.map(|o| win_start + o as u64)
|
||||
.filter(|&s| s < end)
|
||||
.filter(|&s| s < end && end - s < 1_500_000)
|
||||
.unwrap_or(anchor);
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user