# game471.exe: Notes (Static Analysis) Goal: understand file formats and runtime behavior for a clean-room reimplementation. This is based on static inspection (strings + disassembly). No patching, no runtime hooking. ## Quick Map - Stage file path templates referenced in code: - `data/stage/%s.dat` - `data/stage/%s_ext.dat` - `data/stage/%s_clip.dat` - The game uses a background file reader (a small work queue + thread) that: 1. Opens a file path stored in a request object. 2. Reads the file into a buffer in 64 KiB chunks. 3. Calls a callback function provided when the queue was created. ## Background File Reader ### Worker thread proc - `0x004ca320` is used as the thread entry function (passed as a function pointer). - It receives a small argument struct, extracts two pointers from it, then calls `0x004ca070`. ### File read loop - `0x004ca070` implements: - open file (`CreateFileA`), then `ReadFile` in a loop - destination buffer is `request->buf` at offset `+0x204` - expected size is `request->size` at offset `+0x208` At several points it calls a callback: - `queue->callback_fn` stored at offset `queue + 0x120` - `queue->callback_ctx` stored at offset `queue + 0x124` (passed as last argument) ### Queue initialization - `0x004ca5f0` looks like the queue constructor/initializer. - It stores its arguments into `this+0x120` and `this+0x124`. So, to find the actual parsing logic for a specific file type, you typically follow the callback function pointer passed into the queue creation. ## Stage Loader Entry There is code that formats the stage file path strings and attempts to open: - `data/stage/.dat` - `data/stage/_ext.dat` - `data/stage/_clip.dat` This is a good anchor when looking for where the game requests reads for stage containers. Static addresses in the 4.71 executable: - `0x0063ea70`: formats all three stage paths and requests each resource. - `0x00427730`: resource request wrapper. - `0x004279d0`: creates the 0x4c-byte resource object (Ghidra mislabels it as an MFC `CreateObject`). - `0x0063ecc0`: completion/cleanup callback which unwraps three resources before passing the result onward. ## Main stage header The file is big-endian. The initial words are not all section offsets. Confirmed section slots are: | Index | Meaning | | ---: | --- | | 0 | stage config | | 1 | track draw distances | | 2 | track points | | 3 | notes | | 4 | camera | | 5 | particles | | 6 | visualizer | | 7 | unknown section | | 8 | first color table | | 9 | objects | | 10 | scalar/unknown; often `0x30`, not an offset | | 11 | second color table | | 12 | scalar/unknown; can accidentally look like an in-file offset | This matters because treating every plausible header word as an offset can split the notes or camera section at a false boundary. Older 11-word stage headers instead store the second color-table offset in slot 10. The scalar/offset/scalar arrangement in slots 10..12 belongs to the newer 13-word format used by the 4.71-era charts. ## Note array: confirmed wire layout The note section starts at header word 3 and ends exactly at header word 4. Its layout is: ```text u32be name_count // observed: 0 in all 2,968 dumped main charts repeat name_count times: string8 name // u8 length followed by bytes u32be count repeat count times: u32be time_ms u8 raw_type u8 type_override s16be params16[9] u8 flag24 f32be params25[3] u8 flag37 u8 flag38 f32be params39[4] u32be params55[3] f32be param67 u32be param71 f32be params75[5] u32be param95 ``` Thus each note record is exactly 99 bytes and the invariant is: ```text camera_offset - notes_offset == 8 + encoded_names_size + count * 99 ``` The invariant holds on tested charts of very different sizes. For example, `ac_10pt8tion_hard.dat` has 669 records from 6162 through 125568 ms. Its raw-type histogram is `01:366, 02:231, 03:1, 04:3, 05:3, 09:63, 0f:2`. Observed raw types over the parsed corpus are `00, 01, 02, 03, 04, 05, 06, 09, 0a, 0f`. The payload field meanings remain unnamed until backed by code references or controlled runtime observations. The exact read order above comes from the game's stage constructor `0x005ed4c0` and its note-section parser `0x005ea800`. The latter allocates a 0x11c-byte runtime object for every compact 99-byte wire record. It also proves one behavior: if `type_override` is non-zero, the runtime replaces `raw_type` with type 1. The post-load pass at `0x005ebaa0` additionally proves that runtime types 11, 12, 13 and 14 are compatibility aliases: 11 becomes 10, 12/14 become 9, and 13 becomes 4. Other game modes can deliberately simplify types (for example 2 to 1, and 4/5/10/15 to 3), so a clean player should preserve both the raw wire type and the effective runtime type. Full-dump validation with `tools/scan_gc_notes.py GC/data/stage` covers 2,968 main stage files and 1,080,259 notes with zero size mismatches. All of those files currently have zero note-name strings. The aggregate raw-type counts are: ```text 00:6 01:719892 02:163462 03:67753 04:8651 05:5115 06:193 09:102699 0a:6360 0f:6128 ``` `type_override` is non-zero in 52,506 records, so it cannot be discarded. ## Confirmed type names The executable contains a direct pointer table at `0x0077d480`; its array index is the numeric note type. This yields the complete original enum: | Value | Original name | | ---: | --- | | 0 | NONE | | 1 | NORMAL | | 2 | FLICK | | 3 | HOLD | | 4 | SCRATCH | | 5 | BEAT | | 6 | MERRY GO ROUND | | 7 | HIDDEN | | 8 | HIDDEN2 | | 9 | CRITICAL | | 10 | SLIDE HOLD | | 11 | SLIDE COUNTER | | 12 | TURN | | 13 | SPIN | | 14 | FINISH | | 15 | DUAL HOLD | The same 16-entry sequence is repeated in several embedded reflection/debug tables, which independently confirms the ordering. ## Confirmed runtime classifications With the exact names assigned, several behavioral groups are also explicit in code: - `0x005e9480` classifies types 3, 4, 5, 10 and 15 as duration/path notes. - `0x005ebaa0` generates sampled paths for those types. Type 4 additionally builds two derived point arrays; types 3/10/15 share one path branch. - `0x005e94c0` singles out type 6. During gameplay setup it expands that note into several runtime judge objects using the runtime field originating from wire offset `+71`. - Types 1, 2 and 9 follow the non-duration branches in the preview/gameplay render path at `0x006492f0`. The enum names are now exact. The remaining reverse target is the meaning of the compact payload fields and the type-specific hold/slide behavior. ## Appearance, duration and control-helper effects The post-load pass at `0x005ebaa0` converts several compact fields using the BPM active at the note timestamp. With `beat_ms = 60000 / bpm`: | Wire field | Runtime meaning | | ---: | --- | | `+6` | signed marker-effect/UV selector; the note-head draw passes `value - 1` to effect 3 | | `+39` | appearance lead in beats; runtime `+0xbc = max(time - value * beat_ms, 0)` | | `+51` | duration in beats for types 3/4/5/10/15; runtime `+0x58` and `+0xac = time + value * beat_ms` | | `+55` | packed authored `RRGGBBAA` colour used by duration-target geometry | | `+71` | number of generated MERRY GO ROUND targets | | `+75` | MERRY GO ROUND spacing in beats | `0x005efe40` confirms that MERRY's span is `count * spacing * beat_ms`. Gameplay setup allocates three visual handles per generated target, which explains why treating type 6 as one marker loses most of the authored pattern. The four blocks following the BPM table in `StageConfig` are variable-length timing lists, not four fixed records. Each list is `u16be count`, followed by `count` entries of `u32be time_ms, u32be mode, f32be value`. `TuneTimingData::GetTime` selects the last entry at or before the note: mode 1 returns `value` as milliseconds, mode 3 returns the spacing to the next note, and the other modes return `value * beat_ms`. The third list supplies the late boundary used as runtime `+0xac` for ordinary targets. FLICK (and raw type `0x10`) receives an additional literal `0.2 * beat_ms`. Marker opacity is independent of the fixed judgment-window overrides below. `0x0063ff90` stores `beat_ms` at runtime `+0x20`; `0x0064ab80` divides it by the literal `2.0`, while `0x005ebaa0` sets runtime `+0xc4` to `+0xac + 4 * beat_ms`. With `start = +0xbc`, `finish = +0xc4`, and `fade = beat_ms / 2`, the exact unclamped shape is: ```text time < start + fade : alpha = (time - start) / fade time <= finish - fade : alpha = 1 otherwise : alpha = (finish - time) / fade ``` The renderer clamps this to `[0, 1]`. Duration targets use their authored end as `+0xac`; MERRY GO ROUND first adds its generated-target span. The Linux player now carries the per-note BPM and `+0xc4` equivalent instead of using the former guessed 236 ms marker lifetime. The lower-right control helpers are not note-head effects. `0x00661680` reads the 16-entry table at `0x006ea040`, loads the indicated `efcdata.dat` record, and stores it in runtime slot `0xb1 + type`: ```text type: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 effect id: -1 61 66 62 63 64 61 -1 67 65 69 -1 -1 -1 -1 68 ``` Thus NONE and HIDDEN have no helper resource. FLICK and SLIDE HOLD also draw runtime slot `0xb9` as a direction overlay in `0x006492f0`. The leading words of effect records 61--69 give animation lengths of 16, 38, 34, 13, 16, 14, 14, 38 and 38 update ticks respectively. The common-effect interpreter is now mapped far enough to reproduce those records exactly. `0x005f2800` dispatches the child nodes, while `0x005f2030`, `0x005f2250` and `0x005f23f0` implement looping, key lookup and step/linear interpolation. Every child has five relative track offsets. A track begins with `u16be key_count, loop_start, loop_end`, followed by keys of `u16be time, u8 interpolation, f32be values[]`. For a sprite child (type 2), the tracks are: | Track | Values | | ---: | --- | | 0 | UV frame plus six unused/secondary values | | 1 | XYZ position | | 2 | alpha, red, green, blue | | 3 | XY scale | | 4 | rotation in degrees | Type 0 is a parent rotation node in radians. A sprite whose child flag at `+4` is set inherits that rotation, including the rotated XY offset. Effects 61--69 use UV record 13 from `uvdata.dat`, whose image is `img13.bin`: target rings, balls, hand prompts, arrows, action bursts and the type-specific help caption are separate animated layers. `0x006492f0` advances the records with an integer 60 Hz tick, places the selected composite at the literal virtual- screen coordinate `(580, 788, 0)` and applies the literal global XY scale `1.3`. `0x006e9ee4` supplies a 1000 ms look-ahead, so only the current helper is selected; the effect is not cloned onto every world-space note. The Linux player implements this format in `vectorail-gc/src/GcTargetEffect.cpp`; it does not substitute a single guessed icon for the composite any more. ## Cross-version confirmation of note-head rendering The Android ARM64 build preserves C++ symbols which are stripped from the arcade executable. Its `GameScene::DrawGameStageCharacter` establishes the same ordering seen in game471: it disables depth writes, calls `DrawWay`, then calls `DrawMark` without disabling the depth test. The route therefore cannot punch holes through marker sprites drawn afterwards. `TuneGameData::BuildTimingDataSub` (`0x00196a74`) fixes the note head's world position before gameplay by calling `GetWayPosition(note_time)` directly and storing the returned XYZ at runtime `+0xe0`. Long-note endpoints likewise use `GetWayPosition(end_time)` at `+0xf8`. There is no conversion through physical track distance and no note-time lead added in this path. `DrawMark` consumes these stored positions. The separate one-frame `-16.6667 ms` lookup in `GameScene::SetCommonParam` is used for the moving player segment, not for note placement. The Linux player now performs the same direct timestamp lookup for heads, endpoint heads, generated BEAT/MERRY targets, and approach circles. Its shared physical-distance LUT is still needed for duration-body sampling; it now samples each authored segment independently and includes every exact corner. The previous cumulative `t += 0.02` loop could jump across a segment boundary, measure a shortcut chord through the corner, and progressively place later distance-based geometry ahead of the authored route position. `GameScene::DrawMark` creates each head with effect manager group 1, effect 3, and the final selector `wire_mark_effect_id - 1`. Group 1 is bound by `GameScene::LoadSkinData` to the selected skin's `uv.dat` and its texture list. The first signed word at stage-note wire offset `+6` contains the selector source. Examples from `ac_10pt8tion_hard.dat` are: | Note | Wire `+6` | Resolved UV record | | --- | ---: | ---: | | NORMAL | 1 | 1 | | HOLD | 9 | 9 | | FLICK | 10 | 10 | | SCRATCH | 11 | 11 | | BEAT | 12 | 12 | | CRITICAL | 32 | 32 | | DUAL HOLD | 37 | 37 | Effect 3 has a 15-tick loop. Records 1--12 use 32x32 cells from the common skin atlas, while records 32 and 37 use 64x64 cells from the second common atlas. The effect instance uses the literal XY scale `0.025`; consequently the authored 32px and 64px cells occupy 0.8 and 1.6 route-world units. Directional heads have a second world-space layer. `DrawMark` plays effect 39 over FLICK with UV base 0 and over SLIDE HOLD with UV base 10. The final root angle is not wire `+29` used directly: wire `+25` is a vector length and `+29/+33` are HPB angles. `BuildTimingDataSub` converts these through `RotateHPB::ToVector_Deg`; `GameScene::buildGameData` projects the route point and the displaced point with the camera evaluated at the note timestamp, then uses `RotateHPB::SetVector` (`atan2(screen_dx, screen_dy_down)`) and stores the resulting screen angle. It is computed before gameplay and remains fixed while the live camera moves. Effect 39 resolves to a 128x32 animated strip whose arrows extend to the sides of the 32x32 head; it uses the same `0.025` scale, colour and marker alpha. This is independent of the lower-right control helper, even though that helper also has a direction overlay. The marker animation clock is beat-synchronized, not a free-running 60 Hz counter. Android `GameScene::SetCommonParam` computes `phase = 1 - (((time - bpm_change_time) / beat_ms * 2000) % 2000) / 2000` and `DrawMark` writes `phase * EffectData::GetFrameMax()` to effect field `+8`. Thus effect 3 runs backwards from frame 15 toward frame 0 once per beat and resets at every beat; BPM changes also reset the phase origin. BEAT adds `sample_index * 2` to this base frame. The approach ring is geometry, not another atlas layer. `GameScene::drawHitTimingCircle` draws a white 16-segment circle with additive blending, `radius = 0.2 + progress * 0.5`, and alpha `marker_alpha * 128/255`. For ordinary notes `progress = (late_boundary - time) / (2 * (late_boundary - early_boundary))`, capped at 1 and only shown before the target time. The arcade MERRY branch instead evaluates the analogous progress separately at each generated target's shifted timestamp. Both that circle and the effect-3 head are camera-facing billboards. The mobile renderer calls `SetTransposeMatrix(world_position, camera_matrix)` for the circle and leaves the effect sprite in billboard mode; neither is laid in the route tangent plane. Duration bodies remain authored 3D route geometry. The Linux player now decodes this selector, interprets effect 3, loads `skin/common/common.png`, `skin/common/common2.png`, the selected `skinN/img.dat` list and `skinN/uv.dat`, and renders the selected animated UV record. The former guessed `effect/game/img4.bin` glyph mapping and synthetic pulsing rings are no longer used for decoded GC stages. Duration bodies are also type-specific in the executable: - HOLD calls `0x00647cf0` and SLIDE HOLD calls `0x00641fd0`; both emit ribbon triangles rather than an OpenGL-style line. - SCRATCH is sampled every `0.15` world units. `0x005ebaa0` derives two opposing paths with radius `0.2` and rotates the offset by 45 degrees per sample; `0x00641d50` emits six vertices per segment for each path. - BEAT is sampled every `0.55` world units and `0x0064ab80` creates a separate effect at every point. There is no continuous BEAT body. Its case-5 loop draws only points whose sampled timestamp has not passed, and writes `base_frame + sample_index * 2` into effect field `+8`. Android's symbolized `EffectData` access confirms this field is the animation frame; the authored UV-list selector remains unchanged. - MERRY GO ROUND computes the `+0xbc…+0xc4` alpha once outside its generated- target loop and multiplies every copied effect by that same parent alpha. Individual offsets from `0x005efe40` change target time and position, not the fade interval. - DUAL HOLD uses `0x00646d70` to build two ribbon bands. Its literal inner and outer offsets are `0.2` and `0.5`. - Android `GameScene::drawLongShotEffect` (matching arcade `0x0064ab80`) draws an effect-3 marker at the authored endpoint as well as the starting marker. The fixed endpoint UV bases are 29 for HOLD, 30 for SCRATCH, 41 for SLIDE HOLD, and 42 for DUAL HOLD. SLIDE HOLD additionally layers effect 39 with UV base 10 at the endpoint and rotates its root by the precomputed screen direction. Both endpoint layers inherit the long note's colour, fade alpha, billboard transform, beat-synchronised animation and `0.025` sprite scale. ## Tap judgment timing The system-config loader at `0x00635c90` lays out the timing overrides in four per-difficulty arrays. The stage loader applies them in `0x005ed4c0`, and the post-load pass at `0x005ebaa0` derives these runtime note boundaries: | Runtime field | Boundary | Easy override | | ---: | --- | ---: | | `+0x98` | note time minus `MissTimingOverride` | 236 ms | | `+0x9c` | note time minus `UnmuteTimingOverride` | 202 ms | | `+0xa0` | note time plus `LimitTimingOverride` | 202 ms | | `+0xa4` | note time plus `MuteTimingOverride` | 0 ms | `0x005d1690` is the tap-grade function. It selects `+0x9c` for an early tap or `+0xa0` for a late tap and computes the absolute distance `W` from the note time to that boundary. It then builds thresholds using the literal table `{100000, 100, 50, 25}` at `0x006e8bc0`, divided by 100. The grade loop checks indices 3 down to 1, which matches the adjacent original enum/string order `MISS, GOOD, COOL, GREAT`. For an error magnitude `e` and the selected outer input distance `W`, the unclamped comparisons are therefore: ```text GREAT: e < 0.25 * W COOL: e < 0.50 * W GOOD: e < 1.00 * W ``` `GreatMinTime` is copied into the gameplay object at `+0x104`; if the computed GREAT threshold is below that value, it is clamped and COOL is redistributed by the same routine to `great + (good - great) / 3`, capped at GOOD. With the shipped Easy `W = 202`, the absolute-error thresholds are 50.5 ms for GREAT, 101 ms for COOL, and 202 ms for GOOD. HARD/EXTRA use `W = 134`, producing 33.5/67/134 ms. The shipped `GreatMinTime = 32` therefore does not clamp these four standard difficulty windows, but the branch matters for custom configs. `MissMarkOverride=1` in the shipped `data/system.cfg` makes the stage loader replace the timing-list results with these four Simple/Normal/Hard/Extra arrays: ```text MissTimingOverride = (236,202,168,168) UnmuteTimingOverride = (202,168,134,134) LimitTimingOverride = (202,168,134,134) MuteTimingOverride = (0,0,0,0) ``` ## SHOT miss gating The two stage WAVs remain sample-synchronized, but they are not mixed at a fixed level. `FUN_00640790` reads each player's `IsMute` state after the judgment update. For player zero, `FUN_00611920`/`FUN_00611940` pass `1.0` or `0.0` through `FUN_00611800` to the third volume multiplier of channel 13 (SHOT); channel 12 (BGM) is untouched. The write is immediate rather than a timed SoundManager fade. An unresolved target closes the SHOT gate at runtime `+0xa4`, which is note time plus `MuteTimingOverride` (zero in the shipped configuration). The note remains judgeable through `+0xa0`; a successful late judgment opens SHOT again immediately. This is why a miss removes the chart-performance layer without stopping or seeking either WAV. ## Gameplay tap and AD-LIB sound effects The hit sounds are a separate three-WAV sound set; they are not baked into either stage stem. `FUN_00613430` resolves the selected entry from `data/boot/se.dat` and loads its three names from record fields `+0x14`, `+0x18`, and `+0x1c`. The default `se0000` ("Ver.3 Set") maps them to: | Slot | WAV | `SEList.csv` volume | Playback channels | | ---: | --- | ---: | --- | | 0 | `SE_ARRANGE.wav` | 87 | 6, 7 | | 1 | `TAP_SE1.wav` | 80 | 10, 11 | | 2 | `TAP_SE2.wav` | 77 | 8, 9 | Every slot is deliberately double-buffered. `FUN_00611fc0` round-robins slot 0 over channels 6/7. `FUN_00611e70` round-robins the two booster sounds over 10/11 and 8/9. Consequently consecutive taps overlap instead of cutting off the previous sample. Gameplay update `FUN_00640790` consumes the two booster edge flags at player fields `+0xed/+0xee` and the separate AD-LIB flag at `+0xaa`; the booster edge is what starts TP1/TP2, before the resulting note rank is known. A mistimed press still produces its tap sound. The AD-LIB flag selects slot 0 independently. The other `se.dat` entries preserve the same three-slot layout, for example `ALB_06/TP1_06/TP2_06` for the Scratch set. The chosen set id comes from the player customization state (`Afx` state `+0xce0` in the gameplay loader), not from the stage chart. The Linux player currently loads the default Ver.3 set at the exact CSV gains and reproduces the original two voices per slot. It decodes serialized marker byte `+5` into the runtime `+8` AD-LIB flag and starts ALB after a successful rank on such a marker. D-pad inputs are treated as booster 1 and face-button inputs as booster 2; this also matches the PSP-style control grouping. ## Ranked hit effects `setRankedEffect` records the rank and gameplay timestamp. The later `DrawGameStageCharacter` pass selects group-1 effect `rank + 0x1c`, so the effect IDs are 29 MISS, 30 GOOD, 31 COOL, and 32 GREAT. Their decoded lifetimes are respectively 20, 12, 12, and 12 frames. The effect advances from the hit timestamp at 60 Hz, uses additive blending, a global XY scale of `0.03`, a camera-facing matrix, and a stored random root rotation from `rand() % 360`. ## Hit-type dispatch and long-shot ranks The arcade dispatcher at `0x005d5fb0` selects the handler from the effective marker type: 1 TAP, 2 FLICK, 3 HOLD, 4 SCRATCH, 5 BEAT, 6 MERRY, 9 CRITICAL, 10 SLIDE HOLD, and 15 DUAL HOLD. Android's symbolized `GameScene::checkHitMark` independently confirms the same mapping and names type 9 `checkHitMarkDualTap`. Dual tap stores the timestamp of its first independent input, consumes a second independent input, then grades using the first timestamp. DUAL HOLD also waits for two independent inputs, but begins its measured hold duration when the second arrives; releasing either side ends it. Arcade `FUN_005d0d80` grades HOLD and DUAL HOLD by the percentage of the authored body actually covered after clamping press/release to its start/end: ```text GREAT: held > 80% COOL: held > 60% GOOD: held > 40% MISS: otherwise ``` The comparisons are strict. Before computing the percentage it also applies the original short-tail compensation: if 20% of the body is below `66.666664 ms`, that difference is added to covered time. `FUN_005d0eb0` finalizes a still-pressed hold at the authored end; an early release grades it immediately. BEAT (`FUN_005d41b0`) uses the same long-shot percentage grader, but records the first tap and continually replaces the covered endpoint with each later tap. If no fresh trigger arrives for `BeatEnableTime` (200 ms in the shipped config), it finalizes at the last trigger. Thus it measures how far through the body the repeated taps continue, rather than merely counting taps. SCRATCH (`FUN_005d44f0`) listens to four directional triggers. The first one starts coverage; each later trigger extends it only when its direction differs from the previous accepted direction. It finalizes after `ScratchEnableTime` (250 ms) without a direction change or at the body end, then uses the same 40/60/80-percent long-shot grader.