This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Sylpheed-Godot/docs/FORMAT.md
Sylpheed port agent 8d2c092788 docs: FORMAT v2, what P0 decided, and BLOCKED reconciled
FORMAT is bumped to v2 with a "Changes from v1" table giving a reason per row.
v1 was written before HANDOFF answered Q1 and Q3 and before the two-colour
modulate was known; each change is a thing v1 could not have said. The paint
order moves from `unresolved` into the export, because Q3 decoded it — a u16
layer key at +0x0A, stable-sorted — and a decoded answer is read in the
exporter. `paint_order_ties` replaces it, because the tie-break is still
unknown.

Where a layer key is not in the file it comes from the decoders' table of keys
measured off the running game. That is a different kind of fact, so it is
labelled: `layer_source` is "sprite", "implied" or "none".

BLOCKED is reconciled against HANDOFF at /reborn e81dcad. Seven of its ten rows
are answered and are moved out; what remains is Q8 (no cue-to-event binding),
the two Q10 unknowns (which BGM, and where a loop restarts), Q9's unsettled
skippability, Q4's untested NEW GAME, and Q6's undecoded boot driver.

It also raises one question back, found by counting the export rather than by
reverse engineering anything: the decoders document a .t32 element's pivot as
"exactly half the decoded texture's dimensions (verified 7/7 on the tutorial
bundle)", and on GP_TITLE that holds for 55 of 93 sprite-bearing .t32 elements.
38 do not, some grossly — ptlogo_back2 is 1118x262 with pivot (500,117) where
half is (559,131). It changes nothing today, because the exporter emits the
declared pivot and the pivot only matters when scale != 100%. But scale IS
animated here — 177 keyframes across GP_TITLE are not 100%, including on the
title screen P1 has to draw — so the question of what the running game anchors a
scale to is worth an answer before P2. Noted there that the port and
`sylpheed-cli screen render` make the same choice, so a P1 diff cannot
distinguish them and their agreement is not evidence.
2026-08-28 18:43:52 +00:00

13 KiB
Raw Blame History

The open export format — v2

The format the disc is converted into, and the one the Godot project and any modding tool read. It is versioned, so a change is a deliberate act with a version bump, not a silent edit. Changes from v1 is at the bottom, with a reason for each.

Design rules, in priority order:

  1. A human can read and edit it. Modding is a goal of this port, which makes the layout part of the product rather than a temp directory.
  2. Names, never hashes. Where the disc's own name was never recovered — the six *2D archives and GP_READY_ROOM — emit a stable synthetic id and say in the file that the real name is unknown. A modder must be able to tell a recovered name from an invented one.
  3. Provenance travels with the data. Source archive, entry index, exporter version, decoder revision. This is what keeps the export auditable against the disc instead of drifting into an unverifiable fork.
  4. Say what is unknown. A field we could not decode is absent and listed in unresolved — never guessed, never silently defaulted.

JSON, not XML. Godot parses JSON natively with JSON.parse_string; its XMLParser is a SAX-style API that would need a hand-written binding per schema.

The format is executable. sylpheed-export check --out export validates a tree against this document with no disc in hand, reading it the way Godot will — as a stranger. Where the prose here and crates/sylpheed-export/src/check.rs disagree, that is a bug in one of them and worth saying which.

Layout

export/                     # DERIVED. Regenerable. Gitignored. Never hand-edited.
  manifest.json
  screens/title/*.json
  sprites/title/<screen>/*.png
  audio/music/*.ogg  audio/sfx/*.ogg  audio/cues.json
  video/*.ogv
authored/                   # AUTHORED. Hand-written. Committed. Survives re-export.
  screen_names.json         # which build is which screen
  flow.json                 # boot sequence + what each button does
  cue_bindings.json         # which cue fires on move / confirm / back

Sprites are per screen, not a flat pool: a sprite name is unique within a bundle and not across them, and main_menu's ptbase.t32 and extras' ptbase.t32 are different pictures.

authored/screen_names.json is the one authored file the exporter reads; the rest are applied by the runtime over export/.

Common header

{
  "format": "sylpheed.screen/2",
  "exporter": "sylpheed-export 0.1.0",
  "formats_rev": "8b6dbcf",
  "source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 }
}

source.entry is the pak entry index — the stable locator. source.build is the index into that pak's list of screen builds (what sylpheed-cli screen --build N takes), which is stable only as long as the enumeration rule is. formats_rev pins which decoders produced the file.

screens/*.json

{
  "format": "sylpheed.screen/2",
  "exporter": "sylpheed-export 0.1.0",
  "formats_rev": "8b6dbcf",
  "source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 },
  "name": "main_menu",
  "name_source": "authored",
  "name_why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English…",
  "design": [1280, 720],
  "elements": [
    {
      "index": 10,
      "id": "ptbtn01",
      "declared": "ptbtn01.rat",
      "role": "button",
      "kind_raw": "0x3002",
      "sprite": "sprites/title/main_menu/ptbtn01.png",
      "focus_sprite": "sprites/title/main_menu/ptbtn01f.png",
      "opt_link": "ptbtn01f.rat",
      "pivot": [42, 22],
      "layer_source": "sprite",
      "layer": "0x00008110",
      "rest": { "pos": [542, 162], "scale": [100, 100],
                "tint_rgba": "0xffffffff", "fade_argb": "0xffffffff", "t": 64 },
      "keyframes": [
        { "t": 28, "pos": [542, 142], "scale": [100, 100],
          "tint_rgba": "0xffffffff", "fade_argb": "0x00ffffff" }
      ]
    }
  ],
  "paint_order": [1, 3, 4, 2, 5, 8, 9, 6, 7, 15, 10, 11, 12, 13, 14, 0],
  "buttons": ["ptbtn01", "ptbtn02", "ptbtn03", "ptbtn04", "ptbtn05"],
  "unresolved": ["keyframe_time_unit", "paint_order_ties", "fade_out_duration"]
}

name / name_source / name_why

name_source is "authored" or "index" and nothing else. "authored" means the name came from authored/screen_names.json and requires a name_why saying who decided it and on what evidence. "index" means nobody has identified this build and the name is build_NN — a locator, not a claim.

elements[]

index is the declaration index and is also the key paint_order uses; it always equals the element's position in the array. id is declared with its extension stripped.

role comes from the decoded element kind: 0x3002button, 0x10 without a sprite → primitive, 0x0decoration. Anything else is "unknown" with the raw value in kind_raw. Do not invent a name for a kind nobody has decoded.

⚠️ 0x3002 is not a general button test. It is one member of a 0x3000 family with sub-bits, and GP_READY_ROOM uses 0x3000 / 0x3004 / 0x300c / 0x3008 with zero 0x3002. Every screen in this milestone is GP_TITLE, where the mapping is decoded. A consumer meeting role: "unknown" should read kind_raw, not assume.

⚠️ kind & 0x4 is a repeated instance of a template. On the title screen those are motion-trail ghosts and are not on screen at rest — the draw capture shows one quad where the bundle declares three. A runtime should skip a kind & 0x4 element when another element in the same screen has the same id and does not have that bit, and only then: 174 elements on the disc are 0x4 with no such template, and a blanket skip erases them. Both are visible in this format from kind_raw and id.

pivot is the declared pivot, and it is the anchor scale grows aboutpos is the element's top-left at 1:1, and at scale s the drawn top-left is pos pivot·(s1). At 100 % the pivot cancels, which is why it went unnoticed for a long time.

🟡 The decoders document the pivot as "exactly half the decoded texture's dimensions (verified 7/7 on the tutorial bundle)". That does not hold on GP_TITLE: 38 of its 93 sprite-bearing .t32 elements disagree, some grossly (ptlogo_back2, 1118×262, pivot 500,117 where half is 559,131). It is not a problem for this port — the exporter emits the declared pivot and never derives one — but it is a claim a consumer should not lean on. Raised in docs/BLOCKED.md.

sprite / focus_sprite are paths relative to export/. The highlight pairs by name on the sprite — ptbtn01.t32ptbtn01f.t32 — which is 🟡 a naming convention that holds for all 54 real pairs on the disc, not a decoded field.

opt_link is the raw opt link inside the element's .rat record, carried through unresolved. ⚠️ It is not a focus link. That reading was measured and refuted: on the main menu it chains ptloop01 → ptloop02 → ptbtn01, across two decorations and into a button. It is exported so whoever decodes it has it, and named so nothing downstream mistakes it for navigation.

layer / layer_source are the paint-order key. "sprite" means it was read from the u16 at +0x0A of the element's T8aD header — a decoded disc field. "implied" means the element carries no header and the key came from the decoders' table of keys measured off the running game. "none" means neither is known, and the element sorts last. A consumer that needs to know whether a layer is a fact or a measurement reads layer_source.

size appears only on a primitive, which has no texture to take a size from: the quad is pivot × 2, and its colour is the keyframe's fade_argb.

keyframes carry the on-disc time verbatim in t. A keyframe is the start of a ramp toward the next, not a pose that is held, and the ramp is linear. The last keyframe of a group has no t — the disc has no time slot there — and a file that puts one on it is wrong, not merely odd. The unit of t is measured, not on the disc, and so lives in authored/ and is applied in exactly one place.

Two colours multiply. tint_rgba is RGBA and is 0xffffffff on essentially every keyframe; fade_argb is ARGB, and its high byte is the alpha that ramps during a fade. The byte order is in the key name because getting it backwards is silent and looks like an art bug. The drawn modulate is their per-channel product.

rest is the resting pose: the hold — the longest run of consecutive keyframes with an identical pose that does not end the group. Neither the first nor the last keyframe, and not the longest-dwell frame either: a long gap after keyframe k means the screen spends that time arriving at k+1.

paint_order is back-to-front, as declaration indices, and is a permutation of them. It is the stable sort by layer. See unresolved: paint_order_ties.

buttons is navigation order: button-role elements sorted by resting Y. This is geometric, not a decoded neighbour graph — the disc's real navigation structure is unknown. It is right for a vertical menu and should not be trusted for anything else.

unresolved lists what this file does not answer; a consumer needing one of those must get it from authored/. An empty list is a claim that nothing is missing; an absent list is a gap, and check rejects it.

authored/screen_names.json

Which build is which screen, keyed by archive and build index, each with a why. The exporter reads this and stamps name / name_source / name_why into the screen file. A build with no entry exports as build_NN.

authored/flow.json

{
  "format": "sylpheed.flow/1",
  "boot": ["splash_developer", "intro_video", "title", "main_menu"],
  "screens": {
    "main_menu": {
      "actions": {
        "ptbtn01": { "label": "NEW GAME", "goto": "new_game_intro",
                     "why": "label read off the sprite; target is a placeholder for HANDOFF Q4" }
      }
    }
  }
}

goto may name an exported screen or a GamePart id from the executable's own table (29 entries at .rdata 0x820A1630 — that table is a disc fact; which button reaches which entry is Q4 and is not).

export/manifest.json

{
  "format": "sylpheed.manifest/1",
  "exporter": "sylpheed-export 0.1.0",
  "formats_rev": "8b6dbcf",
  "disc": "/disc",
  "screens": [{ "name": "main_menu", "file": "screens/title/main_menu.json",
                "sprites": 18, "missing_sprites": [] }],
  "video_transcode": "ffmpeg -i ADV.wmv -c:v libtheora -q:v 8 -c:a libvorbis -q:a 5 ADV.ogv",
  "warnings": ["GP_READY_ROOM not exported -- out of scope"]
}

video_transcode will record the exact command so a modder can re-run it rather than reverse-engineer what was done. It is absent until P4 writes a video.

Changes from v1

v1 was written before HANDOFF answered Q1 and Q3, and before the two-colour modulate was known. Each change below is a thing v1 could not have said.

Change Why
rest.tint (one #rrggbbaa) → tint_rgba and fade_argb There are two modulate colours on the disc, in different byte orders, and they multiply. One field could not carry both, and a single #rrggbbaa silently discarded the alpha that every fade ramps.
scale is percent integers, not floats It is a percent integer on the disc. Emitting 1.0 invents a precision the file does not have.
paint_order added, "paint_order" dropped from unresolved Q3 decoded it: a u16 layer key at +0x0A, stable-sorted. It is now derived, so it belongs in export/ rather than authored/. paint_order_ties remains unresolved.
layer / layer_source added Some keys are read from the file and some are measured off the running game. A consumer must be able to tell which.
focus_sprite now pairs by sprite name; opt_link exported raw v1 implied opt was the focus link. That was refuted. Pairing by name is the convention that survives.
kind_raw on every element, not only on unknown The 0x3002 button test is not general and kind & 0x4 changes whether an element draws at all. Both need the raw value present unconditionally.
index, declared, parent, size, layer added Needed to reconstruct the screen: paint_order keys on index, primitives have no texture to take a size from, and declared keeps the disc's own spelling next to the derived id.
name_why required whenever name_source is authored Rule 2. A name presented without its evidence is indistinguishable from a recovered one.
sprites moved from sprites/*.png to sprites/<subdir>/<screen>/*.png Sprite names collide across builds. main_menu and extras both ship a ptbase.t32, and they are different pictures.
unresolved is required, and may be empty An empty list is a claim; an absent one is a gap.