diff --git a/crates/skate-game/src/session_marker/mod.rs b/crates/skate-game/src/session_marker/mod.rs index c76cc520..b18fe78f 100644 --- a/crates/skate-game/src/session_marker/mod.rs +++ b/crates/skate-game/src/session_marker/mod.rs @@ -75,6 +75,7 @@ fn suspend( map: Res, menu: Res, replay: Res, + time: Res>, ) { if marker.generation != map.generation { *marker = SessionMarker { @@ -89,6 +90,9 @@ fn suspend( marker.progress = 0.; marker.blocked_until_release = true; marker.ui_time = 0.; + } else if !marker.blocked_until_release { + // PlayerUI runs on the UI clock, independently of physics substeps. + marker.ui_time += time.delta_secs_f64(); } } @@ -100,7 +104,6 @@ fn update( mut skater: ResMut, validation: Res, replay: Res, - time: Res>, mut audio: MessageWriter, ) { if replay.active { @@ -175,10 +178,12 @@ fn update( && !(physics.board_wiping_out && p.skeleton.teleport_pending_604 != 0) && skater.player_input.pending_teleport().is_none(); let distance = session.marker.map_or(0., |m| { - Vec3::from_slice(&transform[3][..3]).distance(Vec3::from_slice(&m.transform[3][..3])) + // 82DB6EC0 -> 82BE1AE8 publishes animation-to-world at output+368; + // UpdateSessionMarker reads its translation at output+416. + Vec3::from_slice(&skater.animated_skeleton.roots.animation_to_world[3][..3]) + .distance(Vec3::from_slice(&m.transform[3][..3])) }); let usable = session.can_return; - session.ui_time += time.delta_secs_f64(); while session.ui_time >= 1. / 60. { session.ui_time -= 1. / 60.; let step = session.hold.update(held, usable, distance, ready); diff --git a/crates/skate-game/src/session_marker/state.rs b/crates/skate-game/src/session_marker/state.rs index 51afed67..e7c1cbff 100644 --- a/crates/skate-game/src/session_marker/state.rs +++ b/crates/skate-game/src/session_marker/state.rs @@ -91,8 +91,18 @@ mod tests { } #[test] fn distance_ramp_matches_native_endpoints() { + assert_eq!(duration(1.), 0.2); assert_eq!(duration(100.), 0.2); assert!((duration(550.) - 0.6).abs() < 0.000001); assert_eq!(duration(1000.), 1.); + assert_eq!(duration(2000.), 1.); + } + #[test] + fn distance_changes_the_actual_relocation_tick() { + for (distance, expected_tick) in [(100., 13), (550., 36), (1000., 61)] { + let mut hold = Hold::default(); + let tick = (1..=120).find(|_| hold.update(true, true, distance, true).relocate); + assert_eq!(tick, Some(expected_tick), "distance {distance}"); + } } } diff --git a/docs/session-markers.md b/docs/session-markers.md index bd99db77..ab7bf45a 100644 --- a/docs/session-markers.md +++ b/docs/session-markers.md @@ -25,12 +25,15 @@ SHA-256 provenance: ```text Owned default.xex: 1db39496585c521d17a2137804f42cf73ebed2b32cac166ec42dbf772f4dcf7f TU3 disassembly image: f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4 -Corrected private manual executable: cf5e5e0cc4ed88547f03a8a1ebe628133d4b58ecc451ecad4d59276d1ba22cbf +HUD-corrected private manual executable: 169d6258cb2a62b4e6caa0ad54788ab66f2d9587231a9d056b9dc984169040d4 ``` | Native owner | Behavior represented | | --- | --- | | `82898FC8` PlayerUI::UpdateSessionMarker | Actions42/43; set/return ordering; retained timer and release latch | +| `82DB6EC0`, `82BE1AE8` | Skeleton output+416 is animation-to-world translation, used for distance to the saved marker | +| `825DF618` FrontEndManager::RenderButton | Full texture-sized quad, minimum corner (-24,-24), rather than the authored 32px placeholder | +| `825D6B68`, `82CA1FD8` | Futura Shadow draws black first; original futuraheavy draws text color at local X+1 | | `8289B6D8`, `8289B140` | Saved transform, foot-forward and on-board byte; marker initially unset | | `8289B928`, `8289BAE0` | Ground/biped eligibility; wheel count, deck Up.Y threshold, excluded states | | `82BFBC18` | Downward10m line from Y+.1; VLT slope/drop/clearance; surface switch | @@ -45,6 +48,10 @@ Corrected private manual executable: cf5e5e0cc4ed88547f03a8a1ebe628133d4b58ecc45 Return duration is.2s through100m,1s from1000m, and a native fused linear ramp between them (`3A690453`, `3DE38E39`). The timer adds `3C888889` per UI tick. +The host budgets those ticks from real UI time, once per frame, and consumes +them alongside controller input; physics substeps no longer supply the clock. +Distance uses the animation-root translation, not the board+.2 capture position. +At 100m, 550m and 1000m the strict comparison fires on ticks 13, 36 and 61. Relocation uses a strict greater-than comparison. Within.5m there is no effect or relocation; the native timer still accumulates. The trigger tick and next two UI ticks publish full effect. The shader's recovered noise contribution uses @@ -71,12 +78,17 @@ existing APT/GEO/RX2/font readers perform extraction; this adapter compiles only the marker subtree. Re-running updates its source cache incrementally. Sources: `hud2/hudphonelist`, imported `controls/button_item2`, the Xbox360 -`button_DPad_Up_hud.Texture` and `button_DPad_Down_hud.Texture`, original Futura -Shadow atlas/metrics and English language labels. The compiler emits ten -triangle meshes and five private RGBA textures. The root `hudintro` endpoint14, -quick-menu `maximized` endpoint49 and two-item label endpoint8 supply the actual +`button_DPad_Up_hud.Texture`, `button_DPad_Down_hud.Texture` and `button_B_hud.Texture`, +original Futura Shadow and futuraheavy atlases/metrics and English language labels. +The compiler emits fifteen triangle meshes and seven private RGBA textures. +`futuraheavy` declares its bitmap font family as `Futura Std Medium`. +The root `hudintro` endpoint14, +quick-menu `maximized` endpoint49 and three-item label endpoint18 supply the actual placements. No replacement typeface or redrawn panel is used. Source texture and extraction-manifest hashes remain in the private compiled manifest. +Textures copy the extracted RGBA payload directly. Object Dropper is shown as +unavailable; its 0.3 opacity is a host presentation choice, not recovered +ActionScript behavior. This change does not add Object Dropper functionality. Runtime accepts `SKATE3_SESSION_MARKER_OVERLAY`, falling back to `ASSET_ROOT/private/session-marker`. This is the narrow interface for a future @@ -105,10 +117,14 @@ directory with a distinct artifact name, checks the CLI/marker strings without executing it, and verifies the staged SHA-256. Use that script for future builds; `-DependencyTargetDirectory` can select an existing dependency cache. -Three standalone hold-state tests passed (cancellation/latching, unset/nearby/ -blocked returns and tail, distance ramp). The WGSL passed offline Naga27 parsing +Four standalone hold-state tests passed (cancellation/latching, unset/nearby/ +blocked returns and tail, distance ramp, actual distance-dependent trigger ticks). +The WGSL previously passed offline Naga27 parsing and validation. Original HUD manifest counts, triangle structure and every RGBA payload size passed data checks. These checks initialize no game or GPU device. +A CPU-only rendering of the compiled HUD was inspected for font layering, +icon size and the three-row panel. The corrected release was rebuilt and staged +through `Build-SessionMarker.ps1`; its artifact/hash checks passed. Manual validation remains necessary: place/replace, return from riding and biped states, switch/fakie stance, interrupted holds, bail recovery, camera cuts, diff --git a/tools/extract_session_marker.py b/tools/extract_session_marker.py index 38eb03c3..71edf053 100644 --- a/tools/extract_session_marker.py +++ b/tools/extract_session_marker.py @@ -25,9 +25,11 @@ def compile_hud(cache_root: Path, output: Path) -> None: if character["type_name"] in ("sprite", "animation"): count = character["movie"]["frame_count"] if owner["key"] == bundle: - label = {0: "hudintro", 17: "maximized", 16: "2"}.get(character["id"]) + label = {0: "hudintro", 17: "maximized", 16: "3"}.get(character["id"]) if label: return {"frame": playback_frame(character, label, play=True)} if count <= 1: return {"frame": 0} + if character["type_name"] == "text" and "/text2/" in path: + return {"text": language["ID_PHONELIST_OBJECTDROPPER"]} return {} flat = SceneFlattener(cache, state).flatten(bundle, 0) @@ -39,14 +41,13 @@ def compile_hud(cache_root: Path, output: Path) -> None: def texture(path, width=None, height=None): path = str(path) if path in texture_ids: return texture_ids[path] - from PIL import Image src = cache_root / path - with Image.open(src) as im: - im = im.convert("RGBA") - width, height = im.size - index = len(textures) - name = f"texture-{index}.rgba" - (output / name).write_bytes(im.tobytes()) + data = src.read_bytes() + if width is None or height is None or len(data) != width * height * 4: + raise ValueError(f"Invalid source RGBA texture: {path}") + index = len(textures) + name = f"texture-{index}.rgba" + (output / name).write_bytes(data) texture_ids[path] = index textures.append({"file": name, "width": width, "height": height, "source": path, "sha256": hashlib.sha256(src.read_bytes()).hexdigest()}) @@ -57,39 +58,41 @@ def compile_hud(cache_root: Path, output: Path) -> None: meshes = [] for primitive in flat["primitives"]: tex = primitive.get("texture") - index = texture(tex["preview"]) if tex else None + index = texture(tex["rgba"], tex["width"], tex["height"]) if tex else None role = "art" if "mButtonRender" in primitive["path"]: - role = "return" if "/mButton0/" in primitive["path"] else "place" - name = "button_DPad_Up_hud.Texture" if role == "return" else "button_DPad_Down_hud.Texture" + role = ("return" if "/mButton0/" in primitive["path"] else + "place" if "/mButton1/" in primitive["path"] else "dropper") + name = {"return": "button_DPad_Up_hud.Texture", + "place": "button_DPad_Down_hud.Texture", "dropper": "button_B_hud.Texture"}[role] item = next(t for t in buttons if t["name"] == name) - index = texture(buttons_dir / item["preview_file"]) - # SetButtonType replaces the authored 32px button render rectangle. - # Preserve its original geometry/placement and bind the retail icon. - inv = primitive["matrix"] - for triangle in primitive["triangles"]: - for vertex in triangle: - x, y = vertex["position"] - vertex["uv"] = [(x - inv[4] + 16.5) / 32, (y - inv[5] + 16.5) / 32] + index = texture(buttons_dir / item["rgba_file"], item["width"], item["height"]) + # RenderButton 825DF618 replaces the placeholder: full texture size, + # with (dimension - 48)/2 offset before +/- dimension/2. + corners = [(-24, -24, 0, 0), (item["width"]-24, -24, 1, 0), + (item["width"]-24, item["height"]-24, 1, 1), + (-24, item["height"]-24, 0, 1)] + vertices = [{"position": transform_point(primitive["matrix"], [x, y]), "uv": [u, v]} + for x, y, u, v in corners] + primitive["triangles"] = [[vertices[i] for i in indices] for indices in ((0,1,2),(0,2,3))] primitive["color"] = [1, 1, 1, primitive["color"][3]] + if role == "dropper": primitive["color"][3] *= 0.3 meshes.append({"texture": index, "role": role, "color": primitive["color"], "vertices": [dict(position=v["position"], uv=v.get("uv", [0, 0])) for tri in primitive["triangles"] for v in tri], "order": primitive["draw_order"], "source": primitive["path"]}) - for text in flat["text"]: - value = language.get(text["value"], text["value"]) - if value.startswith("ID_"): raise ValueError(f"Missing retail text {value}") - font = text["font_asset"] + def emit_text(text, value, font, tint, offset, role): definition = font["definition"] metrics = measure_bitmap_text(definition, value, text["font_height"]) - index = texture(font["preview"]) + size = definition["textures"][0] + index = texture(font["texture"], size["width"], size["height"]) atlas = textures[index] glyphs = {g["glyph_index"]: g for g in definition["glyphs"]} vertices = [] scale = metrics["scale"] for used in metrics["glyphs"]: g = glyphs[used["glyph_index"]] - x = text["bounds"][0] + 2 + used["left"] + x = text["bounds"][0] + 2 + used["left"] + offset y = text["bounds"][1] + 2 + text["font_height"] - g["y_offset"] * scale w, h = g["width"] * scale, g["height"] * scale a, b, c, d = g["atlas_bounds"] @@ -98,18 +101,35 @@ def compile_hud(cache_root: Path, output: Path) -> None: px,py,u,v = corners[corner] vertices.append({"position": transform_point(text["matrix"], [px,py]), "uv": [u/atlas["width"],v/atlas["height"]]}) + meshes.append({"texture": index, "role": role, "color": tint, + "vertices": vertices, "order": text["draw_order"] + offset * 0.1, + "source": text["path"], "font_pass": "shadow" if offset == 0 else "foreground"}) + + for text in flat["text"]: + value = language.get(text["value"], text["value"]) + if value.startswith("ID_"): raise ValueError(f"Missing retail text {value}") + role = ("return" if "/text0/" in text["path"] else + "place" if "/text1/" in text["path"] else "dropper") argb = int(text["color_argb"].lstrip("#"), 16) - meshes.append({"texture": index, "role": "return" if "/text0/" in text["path"] else "place", - "color": [((argb >> n) & 255)/255 for n in (16,8,0)] + [text["alpha"]*((argb>>24)&255)/255], - "vertices": vertices, "order": text["draw_order"], "source": text["path"]}) + color = [((argb >> n) & 255)/255 for n in (16,8,0)] + [text["alpha"]*((argb>>24)&255)/255] + if role == "dropper": color[3] *= 0.3 + # AllocateString 825D6B68 / SkateAptString::Render 82CA1FD8: + # Futura Shadow is black, then futuraheavy in the text color at x+1. + passes = [(text["font_asset"], [0, 0, 0, color[3]], 0), + (cache.font_asset("Futura Std Medium"), color, 1)] + for font, tint, offset in passes: + if font is None: raise ValueError("Missing original futuraheavy font") + emit_text(text, value, font, tint, offset, role) + manifest = {"version": 1, "canvas": [1280,720], "textures": textures, "meshes": sorted(meshes, key=lambda m:m["order"]), "source_manifest_sha256": hashlib.sha256((cache_root/"manifest.json").read_bytes()).hexdigest(), - "timelines": {"hudintro": [1,14], "hudoutro": [15,30], "maximized": [27,49]}, - "notes": "Authored display lists; two session-marker rows; dynamic button textures resolved by native SetButtonType names."} + "timelines": {"hudintro": [1,14], "hudoutro": [15,30], "maximized": [27,49], "3": [9,18]}, + "notes": "Authored three-row display list; native RenderButton dimensions and dual font passes. Object Dropper is unavailable; its 0.3 opacity is a host presentation choice."} (output/"hud.json").write_text(json.dumps(manifest, indent=2)+"\n") + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--game", type=Path, required=True)