# Draws one exported screen, either at a moment on its timeline or at the # `rest` pose the export declares. # # TIMELINE is the real behaviour and the default. A keyframe is the start of a # LINEAR ramp toward the next, and the unit of `t` comes from # `authored/timing.json` -- it is measured, not on the disc, which is why it is # authored and applied in exactly one place. # # REST reproduces what the export's `rest` field says, which is what # `sylpheed-cli screen render` draws. It is kept so `tools/verify-screen` can # hold both renderers to the same assumption. The two modes DISAGREE on six # elements in this export, and the running game sides with the timeline -- see # `docs/DECISIONS.md`. # # One CanvasItem draws the whole screen in `_draw`, rather than a node per # element. The export's `paint_order` is already back-to-front, so honouring it # is a loop; z-indexing sixteen nodes to reproduce the same order would be the # same information expressed less directly, and would hide a tie behind Godot's # own sibling rules. class_name ScreenView extends Node2D ## Skip `kind & 0x4` template instances that duplicate a plain element. ## docs/FORMAT.md: those are motion-trail ghosts and are not on screen at rest. ## The narrow form of the rule matters -- 174 elements on the disc carry the bit ## with no template to duplicate, and a blanket skip would erase them. const KIND_TEMPLATE_INSTANCE := 0x4 enum Pose { TIMELINE, REST } ## Which pose to draw. TIMELINE walks the keyframes at `time_units`; REST draws ## the export's declared `rest` and is there for renderer-vs-renderer diffing. var pose_mode: Pose = Pose.TIMELINE ## Position on the timeline, in the disc's own keyframe units. `t` is left raw ## everywhere; seconds appear only where `units_per_second` is applied. var time_units: float = 0.0 var units_per_second: float = 60.0 ## Duration of the ramp into the final, untimed keyframe -- the screen playing ## itself out. Authored (`authored/timing.json`): the disc has no time slot on ## that keyframe, so this is the one unknown duration per screen. var exit_ramp_units: float = 24.0 ## While true the screen holds at `rest` and never plays its exit. The ## sequencer clears it to send the screen away. var holding: bool = true var tree: ExportTree = null var screen: Dictionary = {} var textures: Dictionary = {} var skipped: Array[String] = [] var drawn: Array[String] = [] ## Which button is highlighted, by element id. P1 leaves it empty: initial focus ## was measured as unstable boot to boot (HANDOFF Q5) and picking one is an ## authored decision that belongs to P5. var focused_id: String = "" func load_screen(t: ExportTree, name: String) -> bool: tree = t screen = t.screen(name) if screen.is_empty(): push_error(t.error) return false var design: Array = screen.get("design", [1280, 720]) # The export's coordinates are in this space and the viewport matches it, so # a mismatch means the export is not what this project was built to draw. var viewport := Vector2i( ProjectSettings.get_setting("display/window/size/viewport_width"), ProjectSettings.get_setting("display/window/size/viewport_height")) if Vector2i(int(design[0]), int(design[1])) != viewport: push_warning("screen %s is authored at %sx%s, viewport is %s" % [name, design[0], design[1], viewport]) _load_textures() queue_redraw() return true func _load_textures() -> void: textures.clear() for element: Dictionary in screen.get("elements", []): var paths: Array = [element.get("sprite", ""), element.get("focus_sprite", "")] # The focus record's own elements carry their own sprites -- the ring is # only reachable this way. for fe: Dictionary in element.get("focus", {}).get("elements", []): paths.append(fe.get("sprite", "")) for rel: String in paths: if rel != "" and not textures.has(rel): var tex := tree.texture(rel) if tex == null: push_warning(tree.error) else: textures[rel] = tex # `tint_rgba` is RGBA and `fade_argb` is ARGB -- different byte orders, on # purpose, because the disc spells them differently and a silent swap looks like # an art bug rather than a parse bug. They multiply per channel. static func modulate_of(pose: Dictionary) -> Color: var tint := _rgba(pose.get("tint_rgba", "0xffffffff")) var fade := _argb(pose.get("fade_argb", "0xffffffff")) return Color(tint.r * fade.r, tint.g * fade.g, tint.b * fade.b, tint.a * fade.a) static func _rgba(hex: String) -> Color: var v := hex.hex_to_int() return Color8((v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff) static func _argb(hex: String) -> Color: var v := hex.hex_to_int() return Color8((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff, (v >> 24) & 0xff) ## The drawn rectangle of an element at a pose. ## ## `pos` is the top-left at 1:1 and `pivot` is the anchor scale grows about, so ## the top-left moves by `-pivot*(s-1)` and the size is the natural size times ## `s`. At 100 % the pivot cancels, which is why it can be got wrong invisibly. static func placement(pose: Dictionary, pivot: Vector2, natural: Vector2) -> Rect2: var pos := _vec(pose.get("pos", [0, 0])) var s := _vec(pose.get("scale", [100, 100])) / 100.0 return Rect2(pos - pivot * (s - Vector2.ONE), natural * s) static func _vec(a: Array) -> Vector2: return Vector2(float(a[0]), float(a[1])) ## The pose of one element at `time_units`. ## ## A group is `pre-roll -> ramp in -> HOLD -> ramp out -> post-roll`, and a ## screen that has arrived sits on the **hold**. So the timeline plays in and ## stops at `rest`, which is the decoders' identification of that hold and ## carries its own `t`. ## ## It is emphatically NOT "play to the last timed keyframe". The exit is not ## only the final untimed frame -- it can be a long run of TIMED ones. The ## title's `pteff02` holds at `t=46` with the 25 % dim quad at alpha 0x40 and ## then ramps to 0x00 by `t=236`; running to the end drops the dim and makes the ## whole screen ~13/255 too bright. That was measured against a plate-free ## capture of the running title, and it is what corrected this rule. ## ## Before the first keyframe the element holds its first pose -- the pre-roll a ## staggered menu needs, with the five buttons starting at t=28,30,32,34,36. func pose_at(element: Dictionary, t: float) -> Dictionary: var frames: Array = element.get("keyframes", []) var timed: Array = [] for k: Dictionary in frames: if k.has("t"): timed.append(k) if timed.is_empty(): # No timed frame at all: the group is a single static pose. return frames[0] if not frames.is_empty() else element.get("rest", {}) # While holding, stop at the hold: past it the group is ramping out, and a # screen that has arrived and is sitting there is not leaving. if holding: t = minf(t, settle_units(element)) # The exit. The final keyframe carries no `t` -- the disc has no slot for one # -- so it is given a synthetic time `exit_ramp_units` after the last timed # frame and then interpolated like any other. That keeps one code path: the # difference between arriving and leaving is only how far `t` is allowed to # run, not a second kind of animation. # # The whole group plays out, not just the fade quad: on the main menu # pteff00 ramps to opaque black while the labels ramp to transparent and # ptframe1/2 hold. Modelling the exit as a black rect over a frozen screen # was measured and refuted -- see authored/timing.json. var last_frame: Dictionary = frames[frames.size() - 1] if not last_frame.has("t"): var exit_frame := last_frame.duplicate() exit_frame["t"] = float(timed[timed.size() - 1]["t"]) + exit_ramp_units timed.append(exit_frame) if t <= float(timed[0]["t"]): return timed[0] for i in range(timed.size() - 1): var a: Dictionary = timed[i] var b: Dictionary = timed[i + 1] var t0 := float(a["t"]) var t1 := float(b["t"]) if t < t1: # A keyframe is the start of a ramp toward the next, and the ramp is # linear -- measured, `authored/timing.json`. return _lerp_pose(a, b, 0.0 if t1 <= t0 else (t - t0) / (t1 - t0)) return timed[timed.size() - 1] # Channels are integers on the disc. The running game's own fade lands on # `round(255*k/15)`, so rounding -- not truncation -- is what was measured. static func _lerp_pose(a: Dictionary, b: Dictionary, f: float) -> Dictionary: return { "pos": [_ilerp(a["pos"][0], b["pos"][0], f), _ilerp(a["pos"][1], b["pos"][1], f)], "scale": [_ilerp(a["scale"][0], b["scale"][0], f), _ilerp(a["scale"][1], b["scale"][1], f)], "tint_rgba": _hex_lerp(a["tint_rgba"], b["tint_rgba"], f), "fade_argb": _hex_lerp(a["fade_argb"], b["fade_argb"], f), "rotation_deg": _ilerp(a.get("rotation_deg", 0), b.get("rotation_deg", 0), f), } static func _ilerp(a: float, b: float, f: float) -> int: return int(round(a + (b - a) * f)) # Byte-wise, so it works for both orders without knowing which one it has. static func _hex_lerp(a: String, b: String, f: float) -> String: var x := a.hex_to_int() var y := b.hex_to_int() var out := 0 for shift in [24, 16, 8, 0]: out |= (_ilerp((x >> shift) & 0xff, (y >> shift) & 0xff, f) & 0xff) << shift return "0x%08x" % out ## Where one element stops, in keyframe units: its hold. ## ## `rest.t` when the export gives one. An element whose `rest` carries no time is ## a single static pose, and there the last timed keyframe is the same answer. static func settle_units(element: Dictionary) -> float: var rest: Dictionary = element.get("rest", {}) if rest.has("t"): return float(rest["t"]) var last := 0.0 for k: Dictionary in element.get("keyframes", []): if k.has("t"): last = maxf(last, float(k["t"])) return last ## The moment the whole screen has arrived: the last element to reach its hold. func settle_time() -> float: var last := 0.0 for element: Dictionary in screen.get("elements", []): last = maxf(last, settle_units(element)) return last ## The moment the screen has finished playing itself out, in keyframe units -- ## the last element's final timed keyframe plus the authored exit ramp. func exit_time() -> float: var last := 0.0 for element: Dictionary in screen.get("elements", []): var frames: Array = element.get("keyframes", []) if frames.is_empty(): continue var timed_end := 0.0 for k: Dictionary in frames: if k.has("t"): timed_end = maxf(timed_end, float(k["t"])) if not frames[frames.size() - 1].has("t"): timed_end += exit_ramp_units last = maxf(last, timed_end) return last # An element is a ghost only when another element on the same screen carries the # same id *without* the template bit -- the template it is a repeat of. func _template_instance_ids() -> Dictionary: var plain := {} for element: Dictionary in screen.get("elements", []): if int(String(element.get("kind_raw", "0x0")).hex_to_int()) & KIND_TEMPLATE_INSTANCE == 0: plain[element.get("id", "")] = true var ghosts := {} for element: Dictionary in screen.get("elements", []): var kind := int(String(element.get("kind_raw", "0x0")).hex_to_int()) if kind & KIND_TEMPLATE_INSTANCE != 0 and plain.has(element.get("id", "")): ghosts[int(element.get("index", -1))] = true return ghosts ## Draw one textured or solid quad, rotated about its pivot. ## ## The rotation anchor in design space is `pos + pivot`: `pos` is the top-left ## at 1:1, so the pivot point sits `pivot` in from it, and scaling about that ## point is exactly the `pos - pivot*(s-1)` rule the placement already uses. ## ## THE GAME DRAWS ROTATION. Confirmed twice by the RE agent, on different ## screens and different elements -- the title's `ptloop` sweeps declare +30/-45 ## and a GPU capture submits them at +30.26/-45.28, and the main menu's focus ## ring ramps 0 -> 360 with everything else held constant, caught mid-spin in a ## capture. The comparison renderer does not draw it yet, so expect a title ## divergence that means "sylpheed-cli is behind", not "the port is broken". ## ## 🟡 The SIGN is an assumption: the decoder documents `+12` as ## clockwise-positive and Godot's 2D rotation is clockwise-positive in a y-down ## space, so this passes the value straight through. Not yet checked against a ## capture at a known angle. func _draw_quad(tex: Texture2D, rect: Rect2, colour: Color, pivot: Vector2, pos: Vector2, rotation_deg: float) -> void: if is_zero_approx(rotation_deg): if tex != null: draw_texture_rect(tex, rect, false, colour) else: draw_rect(rect, colour, true) return var anchor := pos + pivot draw_set_transform(anchor, deg_to_rad(rotation_deg), Vector2.ONE) var local := Rect2(rect.position - anchor, rect.size) if tex != null: draw_texture_rect(tex, local, false, colour) else: draw_rect(local, colour, true) draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) static func _rot_of(pose: Dictionary) -> float: return float(pose.get("rotation_deg", 0)) ## Draw a focus record's own elements -- the spinning ring and the bright label. ## ## The focused state is NOT a sprite swap. `ptbtn0Nf.rat` declares two elements, ## and the parent bundle declares NO element for the record at all, so the leaf ## is the only source of placement for both and there is nothing to inherit. ## The label is 13 px larger per axis than the base and sits at (-7,-7), which ## keeps the two concentric; drawing it at the base position pushes it 7 px ## down-right and off-centre. func _draw_focus(element: Dictionary) -> void: var focus: Dictionary = element.get("focus", {}) for fe: Dictionary in focus.get("elements", []): var rel: String = fe.get("sprite", "") if rel == "": continue var tex: Texture2D = textures.get(rel) if tex == null: skipped.append("%s (focus sprite failed to load)" % fe.get("id", "")) continue # The ring's rest pose. Its spin is real -- rotation_deg ramps 0 -> 360 # with position, scale and alpha all constant -- but the PERIOD is not # established: the ramp's second keyframe is untimed, and what an untimed # keyframe means inside a leaf (rather than at screen level, where it is # the exit) is untested. So this holds the resting angle and does not # invent a spin rate. var pose: Dictionary = fe.get("rest", {}) var pivot := _vec(fe.get("pivot", [0, 0])) var pos := _vec(pose.get("pos", [0, 0])) _draw_quad(tex, placement(pose, pivot, tex.get_size()), modulate_of(pose), pivot, pos, _rot_of(pose)) drawn.append(fe.get("id", "")) func _draw() -> void: if screen.is_empty(): return var elements: Array = screen.get("elements", []) var ghosts := _template_instance_ids() skipped.clear() drawn.clear() for index: int in screen.get("paint_order", []): var element: Dictionary = elements[index] var id: String = element.get("id", "") if ghosts.has(index): skipped.append("%s (template instance)" % id) continue var pose: Dictionary = element.get("rest", {}) if pose_mode == Pose.REST \ else pose_at(element, time_units) var colour := modulate_of(pose) if colour.a <= 0.0: skipped.append("%s (transparent at rest)" % id) continue var pivot := _vec(element.get("pivot", [0, 0])) var pos := _vec(pose.get("pos", [0, 0])) var rot := _rot_of(pose) # A focused button draws its own record instead of its base sprite. if focused_id == id and element.has("focus"): _draw_focus(element) continue var rel: String = element.get("sprite", "") if focused_id == id and element.get("focus_sprite", "") != "": rel = element["focus_sprite"] if rel != "": var tex: Texture2D = textures.get(rel) if tex == null: skipped.append("%s (sprite failed to load)" % id) continue _draw_quad(tex, placement(pose, pivot, tex.get_size()), colour, pivot, pos, rot) drawn.append(id) elif element.get("role", "") == "primitive" and element.has("size"): # A primitive has no texture; the quad is its declared size and its # colour is the pose's own modulate. _draw_quad(null, placement(pose, pivot, _vec(element["size"])), colour, pivot, pos, rot) drawn.append(id) else: # A .t32 element whose sprite the exporter could not produce. Saying # so is the point -- a silently missing element looks like art. skipped.append("%s (no sprite in the export)" % id)