From 46f56691a449c4b9a3752785e531766d5c108e37 Mon Sep 17 00:00:00 2001 From: leite-matheus <78598648+leite-matheus@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:15:20 -0300 Subject: [PATCH 01/11] Track Lab: find the base model beside a moved .blend and export painted vertex colours A .blend shared or moved keeps naming an absolute model path that is no longer there; the recorded file is now looked for beside the .blend first. Vertex Paint edits on the baked layer reach the export, and meshes without a baked layer use their active colour layer. --- tools/blender/tests/test_blender_operators.py | 138 ++++++++++++++++++ .../track_lab/operators/custom_textures.py | 4 +- tools/blender/track_lab/operators/geometry.py | 28 ++++ .../track_lab/operators/geometry_export.py | 92 +++++++++--- .../blender/track_lab/operators/new_track.py | 41 +++++- 5 files changed, 278 insertions(+), 25 deletions(-) diff --git a/tools/blender/tests/test_blender_operators.py b/tools/blender/tests/test_blender_operators.py index c79a792b..dd43cd8d 100644 --- a/tools/blender/tests/test_blender_operators.py +++ b/tools/blender/tests/test_blender_operators.py @@ -1520,6 +1520,57 @@ def test_geometry_colour_edit(): "which reads back from the encoded model") +def test_geometry_vertex_paint(): + """Paint the ``baked`` layer: the paint reaches the file. + + ``baked`` is what the viewport draws and what Vertex Paint paints, and the + export read only the raw channels, so a repainted track looked lit in + Blender and went out with its old colours - white, for a converted mesh - + with nothing saying so. Every vertex left alone has to keep its bytes. + """ + print("vertex painting the baked layer") + from track_lab import level_model + from track_lab.operators import geometry as geometry_ops + from track_lab.operators import geometry_export + from track_lab.preview import _srgb_to_linear + + path, obj = _import_lake(include_hidden=True) + if path is None: + print(" skip: no extracted level models") + return + + mesh = obj.data + base_model = level_model.load(path) + painted_at = 11 + segment_index, vertex_index = _identity(mesh)[painted_at] + was = tuple(base_model.segments[segment_index].colours[vertex_index]) + now = (12, 200, 90, was[3]) + check(now != was, "the paint differs from what the vertex had") + + layer = mesh.color_attributes[geometry_ops.COLOUR_ATTRIBUTE] + values = [0.0] * (len(layer.data) * 4) + layer.data.foreach_get("color", values) + values[painted_at * 4:painted_at * 4 + 4] = [ + _srgb_to_linear(c / 255.0) for c in now[:3]] + [now[3] / 255.0] + layer.data.foreach_set("color", values) + + edit = geometry_export.build_edited_model(bpy.context) + check(not edit.rebuilt, "painting adds nothing, so the file keeps its layout") + differing = [ + (s, v) + for s, segment in enumerate(base_model.segments) + for v in range(len(segment.colours)) + if tuple(segment.colours[v]) != tuple(edit.model.segments[s].colours[v]) + ] + check(differing == [(segment_index, vertex_index)], + "exactly the painted vertex differs in the file (got %r)" % (differing,)) + check(tuple(edit.model.segments[segment_index].colours[vertex_index]) == now, + "and it carries the paint (%r -> %r, got %r)" + % (was, now, tuple(edit.model.segments[segment_index].colours[vertex_index]))) + check(any("painted" in note for note in edit.notes), + "and the export says it took the paint (notes %r)" % (edit.notes,)) + + def test_unpainted_colour_layer(): """An all-zero colour layer is not a black track, and it says so. @@ -1600,6 +1651,24 @@ def mesh_with(layers): check(str(stats.get("colour_domain")) == "CORNER", "and its domain is reported (got %r)" % (stats.get("colour_domain"),)) + # No 'baked' at all: the layer Blender renders with is the lighting - a + # mesh brought from another game carries it under its own name. + stats = {} + mesh = mesh_with([("Shading_Vertex", "POINT", (1.0, 0.5, 0.25, 1.0))]) + colours = new_track._read_colours(mesh, stats) + check(set(colours) == {(255, 188, 137, 255)}, + "the rendered layer is read when there is no baked one (got %r)" + % (set(colours),)) + check(stats.get("colour_from") == "Shading_Vertex", + "and where it came from is recorded (got %r)" % (stats.get("colour_from"),)) + + # Alpha is linear, as the importer writes it, not sRGB. + stats = {} + mesh = mesh_with([(name, "POINT", (1.0, 1.0, 1.0, 0.5))]) + colours = new_track._read_colours(mesh, stats) + check(set(colours) == {(255, 255, 255, 128)}, + "a half alpha reads back as 128 (got %r)" % (set(colours),)) + def test_geometry_refuses_orphans(): """A vertex with no segment and no face has nowhere to go, and says so. @@ -4153,6 +4222,73 @@ def test_moved_blend_rebuilds_its_textures(): shutil.rmtree(directory, ignore_errors=True) +def test_moved_track_finds_its_base(): + """A track of its own, unpacked somewhere else, still exports. + + The case that shipped: a converted track zipped with its ``-geometry.bin`` + and unpacked on another machine. The object recorded the base's absolute + path, which named a disk that was not there, and the export refused with + the file sitting right beside the ``.blend``. + """ + print("a moved track finds its base model beside the .blend") + from track_lab.operators import geometry as geometry_ops + from track_lab.operators import geometry_export + + fresh() + first = tempfile.mkdtemp(prefix="dkr-base-from-") + second = tempfile.mkdtemp(prefix="dkr-base-to-") + try: + bpy.ops.wm.save_as_mainfile(filepath=os.path.join(first, "carried.blend")) + bpy.ops.mesh.primitive_grid_add(size=3000.0, x_subdivisions=2, + y_subdivisions=2) + result = bpy.ops.dkr.track_from_mesh_blank(keep_source=False) + check(result == {"FINISHED"}, "the mesh converts (%r)" % (result,)) + obj = geometry_ops.geometry_objects(bpy.context)[0] + written = os.path.join(first, "carried-geometry.bin") + check(os.path.isfile(written), "its base is written beside the .blend") + + # A copy: the original folder is still there, and the copy uses its own. + moved = os.path.join(second, "carried-geometry.bin") + shutil.copyfile(written, moved) + bpy.ops.wm.save_as_mainfile(filepath=os.path.join(second, "carried.blend")) + found = geometry_export.base_model_path(bpy.context, obj) + check(os.path.samefile(found, moved), + "a copied folder uses the base beside it, not the original's (%s)" + % found) + + # The move: the original folder is gone. + shutil.rmtree(first) + found = geometry_export.base_model_path(bpy.context, obj) + check(os.path.samefile(found, moved), + "with the original gone, the base beside the .blend is found") + check(geometry_export.build_edited_model(bpy.context) is not None, + "and the track exports") + + # Recorded on Windows, opened anywhere. + recorded = r"Z:\nowhere at all\carried-geometry.bin" + obj[geometry_ops.PROP_MODEL_PATH] = recorded + bpy.context.scene.dkr.geometry_path = recorded + found = geometry_ops.model_path(bpy.context, obj) + check(found and os.path.samefile(found, moved), + "a Windows path names its file on any system (%r)" % found) + + # Not carried along: the refusal says where it has to go. + os.remove(moved) + message = None + try: + geometry_export.base_model_path(bpy.context, obj) + except geometry_export.GeometryExportError as error: + message = str(error) + check(message is not None and "carried-geometry.bin" in message + and "next to the .blend" in message + and "addon preferences" not in message, + "a base left behind is named, with where to put it (%s)" % message) + finally: + fresh() + for directory in (first, second): + shutil.rmtree(directory, ignore_errors=True) + + def test_track_from_mesh_drops_what_it_no_longer_draws(): """Converting again after repainting leaves the old pictures behind. @@ -5512,6 +5648,7 @@ def main(): test_track_from_mesh_with_its_own_textures() test_track_from_mesh_keeps_material_textures() test_moved_blend_rebuilds_its_textures() + test_moved_track_finds_its_base() test_track_from_mesh_drops_what_it_no_longer_draws() test_remove_unused_textures() test_track_from_mesh_survives_ctrl_j() @@ -5537,6 +5674,7 @@ def main(): test_slots() test_partial_export_is_safe() test_geometry_colour_edit() + test_geometry_vertex_paint() test_unpainted_colour_layer() finally: track_lab.unregister() diff --git a/tools/blender/track_lab/operators/custom_textures.py b/tools/blender/track_lab/operators/custom_textures.py index 481237dc..65fc492d 100644 --- a/tools/blender/track_lab/operators/custom_textures.py +++ b/tools/blender/track_lab/operators/custom_textures.py @@ -1205,8 +1205,8 @@ def execute(self, context): ids = [int(record.get("id", 0)) for record in geometry.base_textures(obj)] if all(renumbered_id(i, renumber) == i for i in ids): continue - path = bpy.path.abspath(obj.get(geometry.PROP_MODEL_PATH) or "") - if not obj.get(geometry.PROP_AUTHORED_BASE, False) or not os.path.isfile(path): + path = geometry.model_path(context, obj) + if not obj.get(geometry.PROP_AUTHORED_BASE, False) or not path: self.report( {"ERROR"}, "%s's model file is not one this addon wrote beside the " diff --git a/tools/blender/track_lab/operators/geometry.py b/tools/blender/track_lab/operators/geometry.py index 81bf5af2..0a1bc285 100644 --- a/tools/blender/track_lab/operators/geometry.py +++ b/tools/blender/track_lab/operators/geometry.py @@ -50,6 +50,7 @@ from __future__ import annotations import json +import ntpath import os import traceback @@ -1094,6 +1095,33 @@ def water_summary(obj) -> dict: return {} +def model_path(context, obj) -> str: + """Where the ``.bin`` this mesh was decoded from is now, or ``""``. + + The path is recorded absolute, and a ``.blend`` handed to someone in an + archive, or a folder moved or unpacked somewhere else, leaves it naming a + disk that is not there - while the file sits right beside the ``.blend``, + because that is where :func:`replace_base` and Track From Mesh write it. + So the recorded name beside the ``.blend`` is looked for, and looked for + *first*: a copied folder still has the original's file at the recorded + path, and a texture renumbering that rewrote that one would reach into the + other project. The scene setting is the fallback for a mesh imported + before the object carried the path. + """ + folder = os.path.dirname(bpy.data.filepath) if bpy.data.filepath else "" + for recorded in (obj.get(PROP_MODEL_PATH), context.scene.dkr.geometry_path): + if not recorded: + continue + recorded = str(recorded) + # ntpath splits on both separators, so a path recorded on Windows + # still names its file on a Deck or a Mac. + beside = os.path.join(folder, ntpath.basename(recorded)) if folder else "" + for candidate in (beside, bpy.path.abspath(recorded)): + if candidate and os.path.isfile(candidate): + return candidate + return "" + + def replace_base(context, obj, model, include_hidden=None): """Write ``model`` as the track's own base file and rebuild the mesh from it. diff --git a/tools/blender/track_lab/operators/geometry_export.py b/tools/blender/track_lab/operators/geometry_export.py index 21bf9026..92efd6a3 100644 --- a/tools/blender/track_lab/operators/geometry_export.py +++ b/tools/blender/track_lab/operators/geometry_export.py @@ -41,15 +41,16 @@ - because the values an author sees are normalised, V-flipped and sRGB, and none of those survive the trip back exactly. A segment nobody touched has to come out unchanged, which rules out a lossy conversion anywhere near it. +The one exception is a vertex whose ``baked`` colour no longer shows its raw +channels: the author painted it, and the paint is what goes out. """ from __future__ import annotations +import ntpath import os from typing import Dict, List, Optional, Tuple -import bpy - from .. import (level_model, level_model_edit, level_model_layout, prefs, scene, transparency as looks, water) from . import geometry @@ -149,17 +150,14 @@ def base_model_path(context, obj) -> str: """The ``.bin`` the mesh was decoded from. Recorded on the object at import, because that is the file this particular - mesh's vertex identities are indices into. The scene setting is the - fallback for a mesh imported before the object carried it, and the asset - tree is the last resort for a ``.blend`` moved to another machine. + mesh's vertex identities are indices into; :func:`.geometry.model_path` + finds it there or beside the ``.blend``. The asset tree is the last resort + for a retail track's ``.blend`` moved to another machine - it never holds a + base the author's own track was built on. """ - candidates = [ - obj.get(geometry.PROP_MODEL_PATH), - context.scene.dkr.geometry_path, - ] - for candidate in candidates: - if candidate and os.path.isfile(bpy.path.abspath(candidate)): - return bpy.path.abspath(candidate) + found = geometry.model_path(context, obj) + if found: + return found tree = prefs.resolve(context) source = context.scene.dkr.source_path or context.scene.dkr.geometry_path @@ -167,12 +165,22 @@ def base_model_path(context, obj) -> str: if level is not None and level.model_path and os.path.isfile(level.model_path): return level.model_path - named = next((c for c in candidates if c), None) + named = obj.get(geometry.PROP_MODEL_PATH) or context.scene.dkr.geometry_path + shown = ntpath.basename(str(named)) if named else "an unrecorded file" + if obj.get(geometry.PROP_AUTHORED_BASE, False): + raise GeometryExportError( + "the track geometry is built on %s, a file this addon wrote beside " + "the .blend, and it is neither beside this .blend nor where it was " + "written. It is the only copy of the base the edits are applied to " + "- the game's assets do not have it - so put it back next to the " + ".blend, and keep the two together when the track is moved or " + "shared" % shown + ) raise GeometryExportError( - "the track geometry was imported from %s, which is not there any more, " - "so there is no base model to apply the edits to. Re-import the " - "geometry, or point the addon preferences at the extracted assets" - % (os.path.basename(named) if named else "an unrecorded file") + "the track geometry was imported from %s, which is neither where it was " + "nor beside the .blend, so there is no base model to apply the edits " + "to. Re-import the geometry, or point the addon preferences at the " + "extracted assets" % shown ) @@ -488,7 +496,13 @@ def read_mesh(obj, model, translucency=None, fades=None, for name in geometry.ATTR_COLOUR_CHANNELS] colours = (list(zip(*channels)) if all(c is not None for c in channels) else [geometry.unpack_colour(value) for value in raw_colours]) + colours, painted = _take_paint(mesh, colours, model, segments_biased, + vertices_biased) read = MeshRead() + if painted: + read.notes.append( + "%d vertex colour(s) taken from the '%s' layer, where they had been " + "painted" % (painted, geometry.COLOUR_ATTRIBUTE)) matrix = obj.matrix_world # Pool order is the file's order wherever the file still decides it: a @@ -586,6 +600,50 @@ def read_mesh(obj, model, translucency=None, fades=None, return read +def _take_paint(mesh, colours, model, segments_biased, vertices_biased): + """Where the author painted the ``baked`` layer, the paint wins. + + ``baked`` is what the viewport draws and what Vertex Paint paints, but the + raw channels are what reaches the file, so a repaint used to be shown and + then dropped. The raw channels stay the source wherever the layer still + shows them: every byte survives sRGB to linear and back exactly (see + :func:`..preview._linear_to_srgb`), so an untouched vertex compares equal + and keeps its bytes. A vertex whose raw channels were themselves changed + from the base model keeps them too - that edit was made on the attribute + the export reads, on purpose. + + Returns the colours and how many came from the paint. + """ + layer = mesh.color_attributes.get(geometry.COLOUR_ATTRIBUTE) + if layer is None or layer.domain != "POINT" or len(layer.data) != len(colours): + return colours, 0 + values = [0.0] * (len(layer.data) * 4) + layer.data.foreach_get("color", values) + + from ..preview import _linear_to_srgb # noqa: PLC0415 - optional helper + + def byte(value): + return max(0, min(255, int(round(value * 255.0)))) + + taken = list(colours) + painted = 0 + for at, raw in enumerate(colours): + shown = values[at * 4:at * 4 + 4] + paint = tuple(byte(_linear_to_srgb(c)) for c in shown[:3]) + (byte(shown[3]),) + raw = tuple(raw) + if paint == raw: + continue + segment = segments_biased[at] - 1 + vertex = vertices_biased[at] - 1 + if (0 <= segment < len(model.segments) + and 0 <= vertex < len(model.segments[segment].colours) + and raw != tuple(model.segments[segment].colours[vertex])): + continue + taken[at] = paint + painted += 1 + return taken, painted + + def _map_position(matrix, vertex, at): """One vertex in the ``s16`` the file stores, refused rather than wrapped.""" position = scene.to_map(matrix @ vertex.co) diff --git a/tools/blender/track_lab/operators/new_track.py b/tools/blender/track_lab/operators/new_track.py index c28e837a..e839c640 100644 --- a/tools/blender/track_lab/operators/new_track.py +++ b/tools/blender/track_lab/operators/new_track.py @@ -323,6 +323,14 @@ def _read_colours(mesh, stats=None) -> list: white = (255, 255, 255, 255) stats = {} if stats is None else stats layer = mesh.color_attributes.get(geometry.COLOUR_ATTRIBUTE) + if layer is None: + # No 'baked' at all. The layer Blender draws and renders with is the + # paint the author is looking at, and a mesh brought from another game + # carries its lighting there under its own name - an MK64 import's + # 'Shading_Vertex'. Building that white said nothing at all. + layer = _default_layer(mesh) + if layer is not None: + stats["colour_from"] = layer.name if layer is not None and layer.domain != "POINT": stats["colour_domain"] = layer.domain layer = None @@ -347,19 +355,30 @@ def _read_colours(mesh, stats=None) -> list: from ..preview import _linear_to_srgb # noqa: PLC0415 - optional helper + def byte(value): + return max(0, min(255, int(round(value * 255.0)))) + colours = [] for index in range(len(mesh.vertices)): at = index * 4 if at + 4 > len(values): colours.append(white) continue + # Alpha is stored linear, as the importer writes it - only the colour + # channels went through sRGB. colours.append(tuple( - max(0, min(255, int(round(_linear_to_srgb(values[at + c]) * 255.0)))) - for c in range(4) - )) + byte(_linear_to_srgb(values[at + c])) for c in range(3) + ) + (byte(values[at + 3]),)) return colours +def _default_layer(mesh): + """The colour layer Blender renders with, or ``None``.""" + attributes = mesh.color_attributes + name = getattr(attributes, "default_color_name", "") or attributes.active_color_name + return attributes.get(name) if name else None + + # --------------------------------------------------------------------------- # The mesh's own pictures # --------------------------------------------------------------------------- @@ -835,6 +854,12 @@ def refuse(message): operator.report({"WARNING"}, warning) for warning in _texture_warnings(adopted, stats, uv_layers): operator.report({"WARNING"}, warning) + if stats.get("colour_from") and not stats.get("colour_domain"): + operator.report( + {"INFO"}, + "the baked lighting was taken from the '%s' colour layer, the one " + "Blender renders with" % stats["colour_from"], + ) parts = [] if donor: @@ -906,15 +931,19 @@ def _texture_warnings(adopted, stats, uv_layers) -> list: # Saying so is the point. The track still builds, and a white track looks # plausible enough that an author can ship it without ever learning that # the lighting they painted is not in it. - if stats.get("colour_pristine") or stats.get("colour_domain"): + if (stats.get("colour_pristine") or stats.get("colour_domain") + or stats.get("colour_elsewhere")): if stats.get("colour_pristine"): reason = ("the '%s' colour layer has never been painted" % stats["colour_pristine"]) - else: + elif stats.get("colour_domain"): reason = ("the '%s' colour layer is on the %s domain, and the " "baked lighting is read from Vertex" - % (geometry.COLOUR_ATTRIBUTE, + % (stats.get("colour_from", geometry.COLOUR_ATTRIBUTE), str(stats["colour_domain"]).title())) + else: + reason = ("the mesh has no '%s' colour layer" + % geometry.COLOUR_ATTRIBUTE) message = ( "%s, so the track was built with white lighting. Vertex colour is " "multiplied into the texture, so this is what keeps a track from " From e45dc218ae32d1e3d80a137b73e54af38db91ad0 Mon Sep 17 00:00:00 2001 From: leite-matheus <78598648+leite-matheus@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:15:21 -0300 Subject: [PATCH 02/11] Admit custom character presentation when auto boot skips character select --- .../src/game/mods/legacy_character_presentation.hpp | 1 + runtime-recomp/src/game/runtime_legacy_mods.cpp | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/runtime-recomp/src/game/mods/legacy_character_presentation.hpp b/runtime-recomp/src/game/mods/legacy_character_presentation.hpp index a433f04d..f57bb6be 100644 --- a/runtime-recomp/src/game/mods/legacy_character_presentation.hpp +++ b/runtime-recomp/src/game/mods/legacy_character_presentation.hpp @@ -13,6 +13,7 @@ class CharacterPresentation { public: void initialize(std::span,const recomp_context&,const CharacterPresentationCalls&, const std::vector&,std::span sample_addresses); + bool ready()const{return ready_;} // Unused library entries allocate no guest resources. Loaded resources // remain immutable until shutdown, including previously queued sounds. void prepare(std::span,const recomp_context&,const CharacterPresentationCalls&, diff --git a/runtime-recomp/src/game/runtime_legacy_mods.cpp b/runtime-recomp/src/game/runtime_legacy_mods.cpp index 168c9544..eeebc6c7 100644 --- a/runtime-recomp/src/game/runtime_legacy_mods.cpp +++ b/runtime-recomp/src/game/runtime_legacy_mods.cpp @@ -202,6 +202,14 @@ void prepare_presentation(CharacterState& state,std::uint8_t* ram,const recomp_c const auto found=std::ranges::find(entries,state.roster.active(racer),&mods::AllocatedCharacter::id); if(found==entries.end())throw mods::Error("Active presentation character was not admitted."); const auto i=static_cast(found-entries.begin()); + // Character select admits these, and an armed custom track's auto boot + // starts the race without it. Admitting allocates nothing in the guest, + // so the heap order prepare() relies on is the same either way. + if(!state.presentation.ready()) { + std::vector samples; + for(unsigned j=0;jcharacter_race_sample_address(j)); + state.presentation.initialize({ram,guest_memory_size()},ctx,presentation_calls(*p),entries,samples); + } state.presentation.prepare({ram,guest_memory_size()},ctx,presentation_calls(*p),entries,i,assets->character_race_sample_address(i),audio); } std::mutex error_mutex; From a96d18ae2d4dc2279214d3cc34e7c16b1dce1017 Mon Sep 17 00:00:00 2001 From: leite-matheus <78598648+leite-matheus@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:15:22 -0300 Subject: [PATCH 03/11] Modern camera: remove hit shake, keep the eye out of the racer at walls, tie the anchor to the racer during hits --- .../src/game/modern_cam/modern_cam.c | 117 ++++++++-- .../src/game/modern_cam/modern_cam.h | 12 +- runtime-recomp/src/game/modern_camera.cpp | 123 +++++++++- runtime-recomp/src/game/modern_camera.hpp | 4 + runtime-recomp/src/game/modern_camera_dkr.hpp | 16 +- .../src/game/presentation_identity.cpp | 3 + .../src/game/runtime_camera_tuning_ui.inl | 22 +- .../tests/modern_camera_dkr_tests.cpp | 217 ++++++++++++++++-- 8 files changed, 457 insertions(+), 57 deletions(-) diff --git a/runtime-recomp/src/game/modern_cam/modern_cam.c b/runtime-recomp/src/game/modern_cam/modern_cam.c index 7348e8f9..951e0f69 100644 --- a/runtime-recomp/src/game/modern_cam/modern_cam.c +++ b/runtime-recomp/src/game/modern_cam/modern_cam.c @@ -229,6 +229,26 @@ static float mc_fbm(unsigned int seed, float t) { return mc_noise(seed, t) * 0.57f + mc_noise(seed + 17u, t * 2.13f) * 0.29f + mc_noise(seed + 31u, t * 4.37f) * 0.14f; } +/* Ponta do braço a partir da âncora, com essa direção e elevação. */ +static MCVec3 mc_boom_eye(MCVec3 anchor, float camYaw, float elev, float boomLen) { + MCVec3 dir = mc_v3(-MC_SIN(camYaw) * MC_COS(elev), MC_SIN(elev), -MC_COS(camYaw) * MC_COS(elev)); + return mc_add(anchor, mc_scale(dir, boomLen)); +} + +/* Fração livre do braço do pivô até eye (1 = nada no caminho). */ +static float mc_boom_clear(MCRaycastFn raycast, void* user, MCVec3 pivot, MCVec3 eye, float radius) { + float f = 1.0f; + if (raycast && raycast(user, pivot, eye, radius, &f)) { + return mc_clamp(f, 0.0f, 1.0f); + } + return 1.0f; +} + +/* Subida por cima de uma parede: elevações tentadas e o teto delas. */ +#define MC_LIFT_STEPS 5 +#define MC_LIFT_RISE 10.0f +#define MC_ELEV_MAX 1.4f + /* Mola amortecida (massa-mola) integrada em sub-passos de até 1/240 s. */ static void mc_spring(float* x, float* v, float target, float freq, float damp, float dt) { float om = MC_TWO_PI * freq; @@ -274,9 +294,11 @@ void ModernCam_DefaultParams(MCParams* p) { p->heightSharpness = 7.0f; p->airHeightSharpness = 2.5f; p->maxVerticalLag = 2.0f; + p->riseLag = 0.3f; p->slopeFollow = 0.6f; p->slopeSharpness = 4.0f; + p->slopeMax = 0.2f; p->lookAhead = 0.18f; p->lookAheadMax = 3.0f; @@ -300,6 +322,9 @@ void ModernCam_DefaultParams(MCParams* p) { p->collisionRadius = 0.3f; p->collisionReleaseSharpness = 4.0f; + p->collisionPullSharpness = 0.0f; + p->collisionMinDist = 1.6f; + p->collisionLiftMax = 1.0f; p->traumaDecay = 1.4f; p->boostTrauma = 0.15f; @@ -338,7 +363,7 @@ void ModernCam_Reset(MCState* s, const MCParams* p, const MCInput* in) { s->anchor = in->pos; s->lookAheadVec = mc_v3(0.0f, 0.0f, 0.0f); s->speed01 = speed01; - s->slope = in->grounded ? in->slopePitch * p->slopeFollow : 0.0f; + s->slope = in->grounded ? mc_clamp(in->slopePitch * p->slopeFollow, -p->slopeMax, p->slopeMax) : 0.0f; s->fov = p->fovBase + p->fovSpeed * speed01; s->boostFov = 0.0f; s->roll = 0.0f; @@ -346,6 +371,8 @@ void ModernCam_Reset(MCState* s, const MCParams* p, const MCInput* in) { s->orbitPitch = 0.0f; s->idleTime = 0.0f; s->collisionDist = 1.0f; + s->collisionTarget = 1.0f; + s->lift = 0.0f; s->trauma = 0.0f; s->time = 0.0f; s->prevVehicleYaw = s->yaw; @@ -376,7 +403,7 @@ void ModernCam_Update(MCState* s, const MCParams* p, const MCInput* in, float dt MCRaycastFn raycast, void* user, MCOutput* out) { const MCVec3 worldUp = mc_v3(0.0f, 1.0f, 0.0f); const float sc = p->unitScale; - MCVec3 flatVel, pivot, eyeDesired, eye, target, boomDir; + MCVec3 flatVel, pivot, eyeDesired, eye, target; MCVec3 forward, right, camUp; float flatSpeed, speed01, heading, targetYaw, minSpeed; float headingRate, offset, spinW, velYaw, omega; @@ -539,14 +566,32 @@ void ModernCam_Update(MCState* s, const MCParams* p, const MCInput* in, float dt s->anchorVel.x = in->vel.x; s->anchorVel.z = in->vel.z; } + /* Numa batida o carro é jogado para trás e arranca de novo. A mola + passava do ponto, recuava e avançava: a câmera chegava perto do + carro e se afastava. Com hold a âncora fica presa ao carro, como + a câmera original: o rebote leva a câmera junto e a distância não + muda. A velocidade segue a do carro, para soltar sem tranco. */ + if (in->hold > 0.0f && dt > 0.0f) { + float h = mc_clamp(in->hold, 0.0f, 1.0f); + s->anchor.x += (in->pos.x - s->anchor.x) * h; + s->anchor.z += (in->pos.z - s->anchor.z) * h; + s->anchorVel.x += (vel.x - s->anchorVel.x) * h; + s->anchorVel.z += (vel.z - s->anchorVel.z) * h; + } s->prevVel = vel; s->anchor.y = mc_follow_moving(s->anchor.y, s->prevPos.y, in->pos.y, kV, dt); - s->anchor.y = mc_clamp(s->anchor.y, in->pos.y - maxLag, in->pos.y + maxLag); + /* no chão, um carro que sobe (rampa, barranco, parede) leva a câmera + junto: ela nunca fica mais que riseLag abaixo dele olhando para + cima. No ar o atraso inteiro continua, para o pulo não sacudir. */ + s->anchor.y = mc_clamp(s->anchor.y, + in->pos.y - (in->grounded ? mc_min(mc_max(p->riseLag, 0.0f) * sc, maxLag) : maxLag), + in->pos.y + maxLag); } - /* 6. Inclinação do terreno (congela no ar) --------------------------- */ + /* 6. Inclinação do terreno (congela no ar), até slopeMax ------------- */ if (in->grounded) { - s->slope += (in->slopePitch * p->slopeFollow - s->slope) * mc_follow(p->slopeSharpness, dt); + float tilt = mc_clamp(in->slopePitch * p->slopeFollow, -p->slopeMax, p->slopeMax); + s->slope += (tilt - s->slope) * mc_follow(p->slopeSharpness, dt); } /* 7. Look-ahead -------------------------------------------------------- */ @@ -597,27 +642,67 @@ void ModernCam_Update(MCState* s, const MCParams* p, const MCInput* in, float dt dist = mc_lerp(p->distMin, p->distMax, s->speed01) * sc + s->kick; height = mc_lerp(p->heightMin, p->heightMax, s->speed01) * sc; boomLen = MC_SQRT(dist * dist + height * height); - elev = mc_clamp(MC_ATAN2(height, dist) + s->orbitPitch - s->slope, -0.3f, 1.4f); + elev = mc_clamp(MC_ATAN2(height, dist) + s->orbitPitch - s->slope, -0.3f, MC_ELEV_MAX); camYaw = s->yaw + s->orbitYaw + (in->lookBack ? MC_PI : 0.0f); - - boomDir = mc_v3(-MC_SIN(camYaw) * MC_COS(elev), MC_SIN(elev), -MC_COS(camYaw) * MC_COS(elev)); - eyeDesired = mc_add(s->anchor, mc_scale(boomDir, boomLen)); pivot = mc_add(s->anchor, mc_scale(worldUp, p->targetHeight * sc)); /* 9. Colisão: entra na hora, sai devagar ----------------------------- */ + /* Uma parede entre o carro e a câmera encurta o braço, mas nunca a menos + de collisionMinDist do pivô: mais perto que isso a câmera entra no + personagem. Antes de chegar lá ela sobe por cima do carro, o mínimo que + deixa o braço livre (até collisionLiftMax), e desce devagar depois. */ { - float allowed = 1.0f; - if (raycast) { - float f = 1.0f; - if (raycast(user, pivot, eyeDesired, p->collisionRadius * sc, &f)) { - allowed = mc_clamp(f, 0.05f, 1.0f); + float radius = p->collisionRadius * sc; + float minDist = mc_max(p->collisionMinDist, 0.0f) * sc; + float enough = mc_min(minDist * 1.25f, boomLen); + float allowed, floorFrac, pivotLen; + if (p->collisionLiftMax <= 0.0f) { + s->lift = 0.0f; + } + eyeDesired = mc_boom_eye(s->anchor, camYaw, mc_min(elev + s->lift, MC_ELEV_MAX), boomLen); + allowed = mc_boom_clear(raycast, user, pivot, eyeDesired, radius); + if (raycast && p->collisionLiftMax > 0.0f && + (s->lift > 0.0f || (allowed < 1.0f && allowed * boomLen < enough))) { + float liftTarget = p->collisionLiftMax; + int k; + for (k = 0; k <= MC_LIFT_STEPS; ++k) { + float lift = p->collisionLiftMax * (float)k / (float)MC_LIFT_STEPS; + float f = mc_boom_clear(raycast, user, pivot, + mc_boom_eye(s->anchor, camYaw, mc_min(elev + lift, MC_ELEV_MAX), boomLen), + radius); + if (f >= 1.0f || f * boomLen >= enough) { + liftTarget = lift; + break; + } } + s->lift += (liftTarget - s->lift) * + mc_follow(liftTarget > s->lift ? MC_LIFT_RISE : p->collisionReleaseSharpness, dt); + if (liftTarget <= 0.0f && s->lift < 0.001f) { + s->lift = 0.0f; + } + eyeDesired = mc_boom_eye(s->anchor, camYaw, mc_min(elev + s->lift, MC_ELEV_MAX), boomLen); + allowed = mc_boom_clear(raycast, user, pivot, eyeDesired, radius); } + pivotLen = mc_len(mc_sub(eyeDesired, pivot)); + floorFrac = mc_max(pivotLen > minDist ? minDist / pivotLen : 1.0f, 0.05f); + allowed = mc_clamp(allowed, floorFrac, 1.0f); + s->collisionTarget = allowed; if (allowed < s->collisionDist) { - s->collisionDist = allowed; + /* entra rápido, mas desliza: encurtar o braço de uma vez fazia o + olho saltar metros num quadro. collisionPullSharpness 0 (ou + muito alto) volta ao corte seco. */ + if (p->collisionPullSharpness > 0.0f && p->collisionPullSharpness < 100.0f) { + s->collisionDist += (allowed - s->collisionDist) * mc_follow(p->collisionPullSharpness, dt); + } else { + s->collisionDist = allowed; + } } else { - s->collisionDist += (allowed - s->collisionDist) * mc_follow(p->collisionReleaseSharpness, dt); + /* numa batida (hold) o braço não volta a esticar: o carro + quicando tira a parede do caminho e a câmera recuaria junto */ + s->collisionDist += (allowed - s->collisionDist) * + mc_follow(p->collisionReleaseSharpness * (1.0f - mc_clamp(in->hold, 0.0f, 1.0f)), dt); } + s->collisionDist = mc_max(s->collisionDist, floorFrac); } eye = mc_add(pivot, mc_scale(mc_sub(eyeDesired, pivot), s->collisionDist)); target = mc_add(pivot, s->lookAheadVec); diff --git a/runtime-recomp/src/game/modern_cam/modern_cam.h b/runtime-recomp/src/game/modern_cam/modern_cam.h index 1d42a127..49f8c6a6 100644 --- a/runtime-recomp/src/game/modern_cam/modern_cam.h +++ b/runtime-recomp/src/game/modern_cam/modern_cam.h @@ -21,7 +21,9 @@ * - Vertical amortecida separada (pulos e lombadas não chacoalham a tela). * - Acompanha a inclinação da pista suavemente. * - Órbita manual no analógico direito com recentralização automática. - * - Colisão por callback (entra rápido, sai devagar). + * - Colisão por callback (entra rápido, sai devagar). A parede nunca traz + * a câmera para dentro do personagem: antes disso ela sobe por cima do + * carro (collisionMinDist, collisionLiftMax). * - Tremor por "trauma" (boost, aterrissagem), roll leve nas curvas. * - Tudo depende de dt de forma exponencial: o mesmo comportamento a * 20, 30, 60 ou 144 fps. @@ -84,6 +86,7 @@ typedef struct { float stickY; int lookBack; /* 1 = olhar para trás pressionado */ float steer; /* volante, -1..1, + = virando para yaw POSITIVO (0 se não usar a antecipação) */ + float hold; /* 0..1: numa batida, a câmera fica presa ao carro (sem a mola de posição) e o braço não estica */ } MCInput; /* Ajuste fino. ModernCam_DefaultParams preenche valores bons para kart. */ @@ -117,10 +120,12 @@ typedef struct { float heightSharpness; /* vertical no chão (1/s) */ float airHeightSharpness; /* vertical no ar (1/s) */ float maxVerticalLag; /* atraso vertical máximo permitido (m) */ + float riseLag; /* no chão, o máximo que a câmera fica abaixo de um carro que sobe (m) */ /* terreno */ float slopeFollow; /* 0..1, quanto a câmera acompanha a inclinação */ float slopeSharpness; + float slopeMax; /* o máximo que a câmera inclina com o terreno (rad) */ /* look-ahead */ float lookAhead; /* segundos de velocidade somados ao alvo */ @@ -147,6 +152,9 @@ typedef struct { /* colisão */ float collisionRadius; /* (m) */ float collisionReleaseSharpness; + float collisionPullSharpness; /* quão rápido o braço encolhe por uma parede (1/s); 0 = corte seco */ + float collisionMinDist; /* a parede nunca traz a câmera mais perto que isso do pivô (m); 0 desliga */ + float collisionLiftMax; /* quanto a câmera pode subir por cima do carro para fugir da parede (rad) */ /* tremor */ float traumaDecay; /* por segundo */ @@ -187,6 +195,8 @@ typedef struct { float orbitYaw, orbitPitch; float idleTime; float collisionDist; + float collisionTarget; /* até onde a parede deixa o braço ir neste passo (collisionDist desliza até ele) */ + float lift; /* quanto o braço subiu por causa de uma parede (rad) */ float trauma; float time; float prevVehicleYaw; diff --git a/runtime-recomp/src/game/modern_camera.cpp b/runtime-recomp/src/game/modern_camera.cpp index 440bb86f..7d185cd1 100644 --- a/runtime-recomp/src/game/modern_camera.cpp +++ b/runtime-recomp/src/game/modern_camera.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include namespace { @@ -90,6 +91,9 @@ struct Player { dkr_cam::BumpGuard bump{}; std::uint32_t hits = 0U; // seen since this camera started, for the panel bool view_cut = false; // look back toggled since the last step + dkr_cam_vec pivot{}; // what modern_cam aimed at in the last step + double closest = 0.0; // its collisionMinDist, in units + bool sliding = false; // its boom is still sliding in towards a wall }; std::array g_players{}; @@ -117,6 +121,12 @@ std::array g_saved{}; bool g_pending = false; std::uint32_t g_pending_camera = 0U; Fields g_pending_retail{}; +// The Modern camera's own eye and how close to its pivot it may come, for +// commit(); closest is 0 for a look back on the retail camera. +dkr_cam_vec g_pending_eye{}; +dkr_cam_vec g_pending_pivot{}; +double g_pending_closest = 0.0; +bool g_pending_sliding = false; std::uint64_t g_guest_generation = 0U; void reset_guest_state_if_needed() { @@ -129,6 +139,15 @@ void reset_guest_state_if_needed() { g_pending = false; g_pending_camera = 0U; g_pending_retail = {}; + g_pending_eye = {}; + g_pending_pivot = {}; + g_pending_closest = 0.0; + g_pending_sliding = false; +} + +double distance_between(dkr_cam_vec a, dkr_cam_vec b) { + const dkr_cam_vec d = dkr_cam_sub(a, b); + return std::sqrt(dkr_cam_dot(d, d)); } bool camera_slot(std::uint32_t camera, std::uint32_t& slot) { @@ -361,6 +380,10 @@ bool step_modern(std::uint8_t* rdram, recomp_context* context, std::uint32_t obj nose, dt, player.bump, hit_timing); in.yaw = steady.heading; in.vel = steady.velocity; + // While a hit is steadied the camera is tied to the racer, as the retail + // camera is: it rides the bounce at the same distance instead of the + // position spring overshooting, backing off and coming in again. + in.hold = player.bump.blend; if (player.bump.hit) ++player.hits; in.slopePitch = static_cast(dkr_cam::slope_from_racer_pitch(MEM_H(0x02, dkr_cam_addr(object)))); in.grounded = scene.mode == kCameraModePlane || @@ -373,23 +396,28 @@ bool step_modern(std::uint8_t* rdram, recomp_context* context, std::uint32_t obj in.stickY = -input.y.load(std::memory_order_relaxed); in.lookBack = look_back ? 1 : 0; in.steer = dkr_cam::steer_from_racer(static_cast(MEM_B(kRacerSteerAngle, dkr_cam_addr(racer)))); - - // Retail hits and explosions shake the camera; feed them in as trauma. - const float shake = std::fabs(read_float(rdram, scene.camera + 0x30U)); - if (std::isfinite(shake) && player.state.initialized) { - player.state.trauma = std::max(player.state.trauma, - std::min(shake / 12.0F, 0.5F) * tuning.retailShake); - } + // Retail hits and explosions bob the retail camera (Camera +0x30) for a + // second or so. That pose is replaced, and while the bob lasts nothing + // shakes this one either: no trauma, and the bounce landing is no dip. + const float retail_shake = std::fabs(read_float(rdram, scene.camera + 0x30U)); + const bool retail_hit = std::isfinite(retail_shake) && retail_shake > 0.1F; Raycast ray{rdram, scene.model, dkr_cam_authored_radius(scene.layout, MEM_BU(0x9C, dkr_cam_addr(scene.header))), dkr_cam_add(position, dkr_cam_v(0.0, kClearanceHeight, 0.0))}; MCOutput out{}; + if (retail_hit) { + player.state.trauma = 0.0F; + params.boostTrauma = 0.0F; + params.landingTrauma = 0.0F; + } const float trauma_before = player.state.trauma; // While a hit is being steadied the yaw spring is not to keep up with // the nose, and a hop off a wall is not a landing: no dip, no shake. params = dkr_cam::hit_step_params(params, player.bump); - if (player.state.initialized && player.bump.blend > 0.0F) player.state.prevGrounded = in.grounded; + if (player.state.initialized && (player.bump.blend > 0.0F || retail_hit)) { + player.state.prevGrounded = in.grounded; + } const bool landing = player.state.initialized && in.grounded && !player.state.prevGrounded; // modern_cam starts over on its first step and after a teleport. const MCVec3 anchor = player.state.anchor; @@ -422,6 +450,10 @@ bool step_modern(std::uint8_t* rdram, recomp_context* context, std::uint32_t obj return false; } player.view_cut = false; + player.pivot = dkr_cam_v(player.state.anchor.x, player.state.anchor.y + params.targetHeight * params.unitScale, + player.state.anchor.z); + player.closest = std::max(params.collisionMinDist, 0.0F) * params.unitScale; + player.sliding = player.state.collisionDist > player.state.collisionTarget + 0.01F; const dkr_cam_vec eye = dkr_cam_v(out.eye.x, out.eye.y, out.eye.z); player.pose.x = eye.x; player.pose.y = eye.y; @@ -435,14 +467,16 @@ bool step_modern(std::uint8_t* rdram, recomp_context* context, std::uint32_t obj std::fprintf(stderr, "[modern-camera][step] port=%zu dt=%.4f speed=%.0f vy=%.0f grounded=%d landing=%d " "retail-shake=%.2f trauma=%.3f->%.3f dip=%.1f kick=%.1f roll=%.2f nose=%.3f heading=%.3f " - "view=%.3f pitch=%.3f eye=(%.0f,%.0f,%.0f) racer=(%.0f,%.0f,%.0f) clear=%.2f " - "bump-hold=%.2f bump-blend=%.2f hit=%d recovered=%.2f drift=%d boost=%.0f fov=%.1f\n", + "view=%.3f pitch=%.3f eye=(%.0f,%.0f,%.0f) racer=(%.0f,%.0f,%.0f) clear=%.2f lift=%.2f " + "bump-hold=%.2f bump-blend=%.2f hit=%d recovered=%.2f drift=%d boost=%.0f fov=%.1f " + "lookback=%d\n", port, dt, std::hypot(player.velocity.x, player.velocity.z), player.velocity.y, - in.grounded, landing ? 1 : 0, shake, trauma_before, player.state.trauma, + in.grounded, landing ? 1 : 0, retail_shake, trauma_before, player.state.trauma, player.state.dipY, player.state.kick, degrees(out.roll), nose, in.yaw, out.yaw, out.pitch, eye.x, eye.y, eye.z, position.x, position.y, position.z, player.state.collisionDist, + player.state.lift, player.bump.hold, player.bump.blend, player.bump.hit ? 1 : 0, player.bump.recovered, - in.drifting, in.boost, player.fov); + in.drifting, in.boost, player.fov, in.lookBack); } else if (trace_level() == 1 && (++player.trace_ticks % 15U) == 0U) { const double speed = std::hypot(player.velocity.x, player.velocity.z); const double motion = speed > 1.0 ? std::atan2(player.velocity.x, player.velocity.z) : 0.0; @@ -577,11 +611,26 @@ bool dkr::runtime::modern_camera::begin(std::uint8_t* rdram, recomp_context* con const std::uint32_t racer = dkr_cam_span(sp, 0x44, 4) ? dkr_cam_word(rdram, sp + 0x3CU) : 0U; if (!follow_camera(rdram, object, fields, scene) || !dkr_cam_span(racer, 0x1F0, 4)) { // Finish, loop and door cameras are retail; start fresh afterwards. + if (player.active && trace_level() >= 1) { + const std::uint32_t camera = dkr_cam_word(rdram, fields[5]); + std::fprintf(stderr, + "[modern-camera] port=%u hands over to the retail camera: game-mode=%u cutscene=%u " + "dialogue=%d camera-mode=%d\n", + static_cast(port), dkr_cam_word(rdram, fields[0]), + static_cast(MEM_BU(0, dkr_cam_addr(fields[3]))), + static_cast(MEM_H(0, dkr_cam_addr(fields[6]))), + dkr_cam_span(camera, kCameraSize, 4) ? static_cast(MEM_H(0x36, dkr_cam_addr(camera))) + : -1); + } player.active = false; if (scene.layout == 0U) release_fov(rdram, context); return false; } if (!player.active || player.header != scene.header || player.camera != scene.camera) { + if (trace_level() >= 1) { + std::fprintf(stderr, "[modern-camera] port=%u takes the camera: camera-mode=%d layout=%u\n", + static_cast(port), scene.mode, scene.layout); + } player = Player{}; player.active = true; player.header = scene.header; @@ -616,6 +665,10 @@ bool dkr::runtime::modern_camera::begin(std::uint8_t* rdram, recomp_context* con player.frame = frame; } write_pose(rdram, scene.camera, player.pose, player.roll); + g_pending_eye = dkr_cam_v(player.pose.x, player.pose.y, player.pose.z); + g_pending_pivot = player.pivot; + g_pending_closest = player.closest; + g_pending_sliding = player.sliding; if (scene.layout == 0U && std::isfinite(g_fov.level) && g_fov.level > 0.0F) { const float current = read_float(rdram, dkr::runtime::revision_addresses::CurrentCameraFov); if (!(std::fabs(current - player.fov) < 0.01F)) set_fov(rdram, context, player.fov); @@ -637,6 +690,8 @@ bool dkr::runtime::modern_camera::begin(std::uint8_t* rdram, recomp_context* con camera.pitch = retail.pitch; write_pose(rdram, scene.camera, dkr_cam::turn_around(camera, pivot.x, pivot.z, dkr_cam::kPi), retail.roll); + g_pending_closest = 0.0; + g_pending_sliding = false; } g_pending = true; g_pending_camera = scene.camera; @@ -653,6 +708,21 @@ void dkr::runtime::modern_camera::commit(std::uint8_t* rdram, const std::uint32_ const std::uint32_t model = dkr_cam_word(rdram, fields[4]); std::uint32_t slot = 0U; if (camera != g_pending_camera || !camera_slot(camera, slot)) return; + // The clearance pass pulls the eye towards the racer to keep it out of a + // wall, but never into the racer: within modern_cam's closest distance + // its own eye stands (it has already risen over what it could). Nor in + // one jump: while modern_cam's boom is still sliding in towards a wall, + // its eye stands too, so a wall cutting the boom is a quick slide rather + // than metres in one frame. + if (g_pending_closest > 0.0) { + const double cleared = distance_between(dkr_cam_position(rdram, camera), g_pending_pivot); + if ((cleared < g_pending_closest * 0.95 || g_pending_sliding) && + distance_between(g_pending_eye, g_pending_pivot) > cleared) { + dkr_cam_store(rdram, camera + 0x0CU, g_pending_eye.x); + dkr_cam_store(rdram, camera + 0x10U, g_pending_eye.y); + dkr_cam_store(rdram, camera + 0x14U, g_pending_eye.z); + } + } // Rendering and BSP selection start from the segment the moved eye is in. // The clearance pass only rewrites it when it had to move the eye. if (dkr_cam_span(model, 0x40, 4)) { @@ -685,6 +755,35 @@ void dkr::runtime::modern_camera::restore(std::uint8_t* rdram, const std::uint32 if (now.segment == saved.written.segment) MEM_H(0x34, base) = saved.retail.segment; } +void dkr::runtime::modern_camera::trace_drawn_pose(std::uint8_t* rdram, std::uint32_t camera_slot) { + if (trace_level() < 2 || camera_slot >= kPlayerCameras || g_online.load(std::memory_order_acquire)) return; + const std::uint32_t camera = dkr::runtime::revision_addresses::Cameras + camera_slot * kCameraSize; + if (!dkr_cam_span(camera, kCameraSize, 4)) return; + const Fields drawn = read_fields(rdram, camera); + const Saved& saved = g_saved[camera_slot]; + if (saved.valid && saved.camera == camera && drawn.x == saved.written.x && drawn.y == saved.written.y && + drawn.z == saved.written.z && drawn.yaw == saved.written.yaw && drawn.pitch == saved.written.pitch && + drawn.roll == saved.written.roll) { + return; + } + bool any = false; + for (const Player& player : g_players) any = any || player.active; + if (!any) return; + const auto f = [](std::uint32_t bits) { + float value; + std::memcpy(&value, &bits, sizeof(value)); + return static_cast(value); + }; + std::fprintf(stderr, + "[modern-camera][drawn] slot=%u pending=%d mode=%d drawn=(%.0f,%.0f,%.0f yaw=%d pitch=%d roll=%d) " + "modern=(%.0f,%.0f,%.0f yaw=%d pitch=%d roll=%d) retail=(%.0f,%.0f,%.0f yaw=%d pitch=%d roll=%d)\n", + static_cast(camera_slot), saved.valid ? 1 : 0, + static_cast(MEM_H(0x36, dkr_cam_addr(camera))), f(drawn.x), f(drawn.y), f(drawn.z), + drawn.yaw, drawn.pitch, drawn.roll, f(saved.written.x), f(saved.written.y), f(saved.written.z), + saved.written.yaw, saved.written.pitch, saved.written.roll, f(saved.retail.x), f(saved.retail.y), + f(saved.retail.z), saved.retail.yaw, saved.retail.pitch, saved.retail.roll); +} + bool dkr::runtime::modern_camera::consume_cut(std::uint32_t camera_slot) { return !g_online.load(std::memory_order_acquire) && camera_slot < kPlayerCameras && g_cut[camera_slot].exchange(false, std::memory_order_acq_rel); diff --git a/runtime-recomp/src/game/modern_camera.hpp b/runtime-recomp/src/game/modern_camera.hpp index 02edcdc9..0ec16a75 100644 --- a/runtime-recomp/src/game/modern_camera.hpp +++ b/runtime-recomp/src/game/modern_camera.hpp @@ -96,4 +96,8 @@ void restore(std::uint8_t* rdram, const std::uint32_t fields[8]); // One-shot interpolation cut for a player camera slot (look-back toggles). bool consume_cut(std::uint32_t camera_slot); +// With the step log on, called as a frame's camera matrix is built: logs the +// pose being drawn when it is not the one the Modern camera left there. +void trace_drawn_pose(std::uint8_t* rdram, std::uint32_t camera_slot); + } // namespace dkr::runtime::modern_camera diff --git a/runtime-recomp/src/game/modern_camera_dkr.hpp b/runtime-recomp/src/game/modern_camera_dkr.hpp index fd84088c..80ab181e 100644 --- a/runtime-recomp/src/game/modern_camera_dkr.hpp +++ b/runtime-recomp/src/game/modern_camera_dkr.hpp @@ -85,9 +85,6 @@ inline float layout_framing(std::uint32_t layout) { // params is in metres with one-player framing; layout_params scales it. struct Tuning { MCParams params{}; - // Retail hits and explosions shake the camera; this scales how much of - // that reaches modern_cam as trauma. - float retailShake = 1.0F; // Orbiting with the mouse, in degrees per 100 pixels of mouse travel. // 0 turns it off. float mouseOrbit = 30.0F; @@ -126,6 +123,9 @@ inline Tuning default_tuning() { // pace of a camera operator, so a drift ending does not swing the view. p.driftAlign = 0.45F; p.alignSharpness = 3.0F; + // A wall cutting the boom slides the eye in over a few frames instead of + // metres in one. + p.collisionPullSharpness = 12.0F; p.yawFreq = 2.3F; p.yawDamping = 1.0F; p.lookAhead = 0.12F; @@ -229,7 +229,7 @@ struct TuningField { // The Bancada's ranges. fovBase is left out: DKR keeps each level's FOV and // only adds modern_cam's change, so the base is not a setting. So is // collisionRadius: the clearance probe uses the level's authored radius. -inline constexpr std::array kTuningFields{{ +inline constexpr std::array kTuningFields{{ {"distMin", &MCParams::distMin, TuningGroup::Framing, "Distance at rest", 1.0F, 10.0F, "m", 2}, {"distMax", &MCParams::distMax, TuningGroup::Framing, "Distance at top speed", 1.0F, 12.0F, "m", 2}, {"heightMin", &MCParams::heightMin, TuningGroup::Framing, "Height at rest", 0.2F, 4.0F, "m", 2}, @@ -251,8 +251,10 @@ inline constexpr std::array kTuningFields{{ {"heightSharpness", &MCParams::heightSharpness, TuningGroup::Follow, "Vertical on the ground", 0.5F, 30.0F, "/s", 1}, {"airHeightSharpness", &MCParams::airHeightSharpness, TuningGroup::Follow, "Vertical in the air", 0.2F, 20.0F, "/s", 1}, {"maxVerticalLag", &MCParams::maxVerticalLag, TuningGroup::Follow, "Maximum vertical lag", 0.2F, 6.0F, "m", 1}, + {"riseLag", &MCParams::riseLag, TuningGroup::Follow, "Below a racer going up, at most", 0.0F, 2.0F, "m", 2}, {"slopeFollow", &MCParams::slopeFollow, TuningGroup::Terrain, "Follows the slope", 0.0F, 1.0F, "", 2}, {"slopeSharpness", &MCParams::slopeSharpness, TuningGroup::Terrain, "Speed", 0.5F, 15.0F, "/s", 1}, + {"slopeMax", &MCParams::slopeMax, TuningGroup::Terrain, "Tilts at most", 0.0F, 45.0F, "\xC2\xB0", 0, true}, {"lookAhead", &MCParams::lookAhead, TuningGroup::LookAhead, "Anticipates the motion", 0.0F, 0.6F, "s", 2}, {"lookAheadMax", &MCParams::lookAheadMax, TuningGroup::LookAhead, "Limit", 0.0F, 8.0F, "m", 1}, {"lookAheadSharpness", &MCParams::lookAheadSharpness, TuningGroup::LookAhead, "Smoothness", 0.5F, 20.0F, "/s", 1}, @@ -273,12 +275,13 @@ inline constexpr std::array kTuningFields{{ {"mouseOrbit", nullptr, TuningGroup::Orbit, "Mouse, 0 = off", 0.0F, 90.0F, "\xC2\xB0/100 px", 0, false, &Tuning::mouseOrbit}, {"collisionReleaseSharpness", &MCParams::collisionReleaseSharpness, TuningGroup::Collision, "Return after a wall", 0.5F, 20.0F, "/s", 1}, + {"collisionPullSharpness", &MCParams::collisionPullSharpness, TuningGroup::Collision, "Comes in for a wall (0 = at once)", 0.0F, 100.0F, "/s", 0}, + {"collisionMinDist", &MCParams::collisionMinDist, TuningGroup::Collision, "Never closer to the racer than", 0.0F, 4.0F, "m", 2}, + {"collisionLiftMax", &MCParams::collisionLiftMax, TuningGroup::Collision, "Rises over the racer up to", 0.0F, 70.0F, "\xC2\xB0", 0, true}, {"traumaDecay", &MCParams::traumaDecay, TuningGroup::Shake, "Decay", 0.2F, 5.0F, "/s", 1}, {"boostTrauma", &MCParams::boostTrauma, TuningGroup::Shake, "On a boost", 0.0F, 1.0F, "", 2}, {"landingTrauma", &MCParams::landingTrauma, TuningGroup::Shake, "On landing", 0.0F, 1.0F, "", 2}, {"landingSpeedForMax", &MCParams::landingSpeedForMax, TuningGroup::Shake, "Fall speed for full shake", 2.0F, 25.0F, "m/s", 1}, - {"retailShake", nullptr, TuningGroup::Shake, "Game hits and explosions", 0.0F, 2.0F, "x", 2, false, - &Tuning::retailShake}, {"shakeFreq", &MCParams::shakeFreq, TuningGroup::Shake, "Frequency", 2.0F, 40.0F, "Hz", 1}, {"shakeMaxAngle", &MCParams::shakeMaxAngle, TuningGroup::Shake, "Maximum angle", 0.0F, 8.0F, "\xC2\xB0", 2, true}, {"shakeMaxOffset", &MCParams::shakeMaxOffset, TuningGroup::Shake, "Maximum offset", 0.0F, 0.5F, "m", 2}, @@ -506,7 +509,6 @@ inline Tuning comfort_tuning() { p.rollMax = 0.0F; p.boostTrauma = 0.0F; p.landingTrauma = 0.0F; - tuning.retailShake = 0.0F; p.enableSteerLead = 0; p.enablePosSpring = 0; p.enableLandingDip = 0; diff --git a/runtime-recomp/src/game/presentation_identity.cpp b/runtime-recomp/src/game/presentation_identity.cpp index a4c784eb..513b2718 100644 --- a/runtime-recomp/src/game/presentation_identity.cpp +++ b/runtime-recomp/src/game/presentation_identity.cpp @@ -612,6 +612,9 @@ void RegisterCameraMatrix(std::uint8_t* rdram, dkr::runtime::presentation::kIgnoredIdentity; return; } + if (matrix_role == 1U) { + dkr::runtime::modern_camera::trace_drawn_pose(rdram, camera_id); + } const std::uint32_t camera = kCamerasAddress + camera_id * kCameraSize; if (!ValidRange(camera, kCameraSize)) { diff --git a/runtime-recomp/src/game/runtime_camera_tuning_ui.inl b/runtime-recomp/src/game/runtime_camera_tuning_ui.inl index 27b3020f..5669ae81 100644 --- a/runtime-recomp/src/game/runtime_camera_tuning_ui.inl +++ b/runtime-recomp/src/game/runtime_camera_tuning_ui.inl @@ -31,10 +31,12 @@ constexpr std::array kCameraTuningGroups{{ {camtune::TuningGroup::Orbit, "Manual orbit", "The right stick, or the mouse while racing (Esc frees the pointer; hold the right button to keep " "the view where you put it). Left alone, the camera drifts back behind the racer.", false}, - {camtune::TuningGroup::Collision, "Walls", "When a wall comes between the camera and the racer.", false}, + {camtune::TuningGroup::Collision, "Walls", + "When a wall comes between the camera and the racer the camera comes in, but never into the racer: " + "first it rises over the racer, as little as clears the wall.", false}, {camtune::TuningGroup::Shake, "Shake", - "Boosts, landings, and the game's own hits and explosions. All at 0 is no shake; the swing " - "when you hit a wall is under Hits.", true}, + "Boosts and landings. All at 0 is no shake. Hits and explosions never shake this camera; " + "the swing when you hit a wall is under Hits.", true}, {camtune::TuningGroup::Hits, "Hits", "Walls and karts stop the racer dead, bounce it off or knock its nose round. Below 100% the camera " "keeps following the motion it had, so its aim, zoom and FOV ignore the stop and the bounce until " @@ -63,11 +65,15 @@ bool SaveCameraTuning(const camtune::Tuning& tuning); void LoadCameraTuning() { camtune::Tuning tuning = camtune::default_tuning(); bool migrated = false; - std::ifstream input(CameraTuningPath(), std::ios::binary); - if (input) { - std::ostringstream text; - text << input.rdbuf(); - tuning = camtune::parse_tuning(text.str(), &migrated); + { + // Closed before the save below: Windows will not replace a file + // that is still open. + std::ifstream input(CameraTuningPath(), std::ios::binary); + if (input) { + std::ostringstream text; + text << input.rdbuf(); + tuning = camtune::parse_tuning(text.str(), &migrated); + } } camtune::set_tuning(tuning); g_camera_tuning.saved = tuning; diff --git a/runtime-recomp/tests/modern_camera_dkr_tests.cpp b/runtime-recomp/tests/modern_camera_dkr_tests.cpp index 5fab99e2..7e2da23c 100644 --- a/runtime-recomp/tests/modern_camera_dkr_tests.cpp +++ b/runtime-recomp/tests/modern_camera_dkr_tests.cpp @@ -141,7 +141,6 @@ bool TuningFileRoundTrip() { tuning.params.yawFreq = 1.234567F; tuning.params.steerLead = 0.3F; tuning.params.enableLandingDip = 1; - tuning.retailShake = 0.5F; tuning.hitReaction = 40.0F; tuning.recenterEase = 1.5F; if (!same_tuning(parse_tuning(serialize_tuning(tuning)), tuning)) return false; @@ -149,6 +148,12 @@ bool TuningFileRoundTrip() { const Tuning odd = parse_tuning("# note\r\nyawFreq=99\r\nnope=1\r\ndistMin=abc\r\n" "rollMax=-5\r\nenableBoostKick=0\r\n"); const Tuning defaults = default_tuning(); + // Hits and explosions no longer shake the camera: a file saved while + // they did still loads, and the setting is not written back. + if (!same_tuning(parse_tuning("version=2\nretailShake=2\n"), defaults) || + serialize_tuning(defaults).find("retailShake") != std::string::npos) { + return false; + } return odd.params.yawFreq == 6.0F && odd.params.distMin == defaults.params.distMin && odd.params.rollMax == 0.0F && odd.params.enableBoostKick == 0 && same_tuning(parse_tuning(""), defaults) && @@ -163,7 +168,7 @@ bool ComfortIsCalm() { const Tuning comfort = comfort_tuning(); const MCParams& p = comfort.params; return p.rollMax == 0.0F && p.rollFromYawRate == 0.0F && p.boostTrauma == 0.0F && - p.landingTrauma == 0.0F && comfort.retailShake == 0.0F && p.fovBoost == 0.0F && + p.landingTrauma == 0.0F && p.fovBoost == 0.0F && p.enableLandingDip == 0 && p.enableBoostKick == 0 && p.enableSteerLead == 0 && p.yawDamping >= 1.0F && p.distMax == p.distMin && p.heightMax == p.heightMin; } @@ -370,10 +375,11 @@ struct WallResult { double aim_swing = 0.0; // units the aim point moved along the nose, relative to the racer double turn = 0.0; // the view's fastest turn, degrees/s double eye_accel = 0.0; // the eye's fastest speed change, units/s^2 + double retreat = 0.0; // units the eye moved back along the nose after the hit bool landed = false; // modern_cam saw a landing during the hit }; -WallResult wall_hit(const Tuning& tuning, bool hop = false) { +WallResult wall_hit(const Tuning& tuning, bool hop = false, bool hold = true) { const MCParams params = layout_params(tuning, 0U); const float dt = 1.0F / 30.0F; MCState s{}; @@ -382,6 +388,7 @@ WallResult wall_hit(const Tuning& tuning, bool hop = false) { WallResult result; const HitTiming timing = hit_timing(tuning.hitReaction); double aim_before = 0.0, fov_before = 0.0, previous_yaw = 0.0, previous_speed = 600.0; + double farthest_along = -1e9; float previous_x = 0.0F, previous_z = 0.0F; const float nx = static_cast(std::sin(kNose)), nz = static_cast(std::cos(kNose)); for (int i = 0; i < 150; ++i) { @@ -399,6 +406,7 @@ WallResult wall_hit(const Tuning& tuning, bool hop = false) { const HitInput given = hit_steady_input(velocity, static_cast(kNose), dt, guard, timing); in.yaw = given.heading; in.vel = given.velocity; + in.hold = hold ? guard.blend : 0.0F; // The wall lifts the wheels for a tick, as a kerb does. in.grounded = hop && (i == 60 || i == 61) ? 0 : 1; const MCParams p = hit_step_params(params, guard); @@ -422,6 +430,11 @@ WallResult wall_hit(const Tuning& tuning, bool hop = false) { aim_before = aim; fov_before = out.fovDeg; } + const double eye_along = out.eye.x * nx + out.eye.z * nz; + if (i >= 59) { + farthest_along = std::max(farthest_along, eye_along); + result.retreat = std::max(result.retreat, farthest_along - eye_along); + } if (i >= 60) { result.nearest = std::min(result.nearest, distance); result.farthest = std::max(result.farthest, distance); @@ -436,22 +449,28 @@ WallResult wall_hit(const Tuning& tuning, bool hop = false) { return result; } -// A wall hit is not a spring any more: the camera moves back with the -// bounce (as retail's does), its aim, zoom and FOV ignore the stop, and -// once the racer is back up to speed the framing is what it was. Raw +// A wall hit is not a spring any more: while DKR throws the racer back and +// it drives off again, the camera stays tied to it as the retail camera +// does, so the distance between them hardly changes (the position spring +// used to overshoot, back off and come in again: the camera approached the +// racer and moved away). Its aim, zoom and FOV ignore the stop. Raw // modern_cam (hits at 100%) still whips round and pumps the FOV. bool WallHitIsNotASpring() { Tuning raw = default_tuning(); raw.hitReaction = 100.0F; const WallResult bare = wall_hit(raw); const WallResult held = wall_hit(default_tuning()); - std::printf("[test][modern-camera] wall at 600 u/s: eye %.0f -> %.0f..%.0f -> %.0f units, aim moved %.0f, " - "fov %.1f deg, view turned %.0f deg/s (raw: eye %.0f..%.0f, aim %.0f, fov %.1f, %.0f deg/s)\n", - held.before, held.nearest, held.farthest, held.settled, held.aim_swing, held.fov_swing, held.turn, - bare.nearest, bare.farthest, bare.aim_swing, bare.fov_swing, bare.turn); - return bare.turn > 100.0 && held.turn < 5.0 && held.nearest > held.before * 0.85 && - held.farthest < held.before * 1.1 && std::fabs(held.settled - held.before) < 8.0 && - held.aim_swing < 40.0 && held.fov_swing < 1.0; + const WallResult sprung = wall_hit(default_tuning(), false, false); + std::printf("[test][modern-camera] wall at 600 u/s: eye %.0f -> %.0f..%.0f -> %.0f units from the racer " + "(spring: %.0f..%.0f), aim moved %.0f, fov %.1f deg, view turned %.0f deg/s (raw: eye %.0f..%.0f, " + "aim %.0f, fov %.1f, %.0f deg/s)\n", + held.before, held.nearest, held.farthest, held.settled, sprung.nearest, sprung.farthest, + held.aim_swing, held.fov_swing, held.turn, bare.nearest, bare.farthest, bare.aim_swing, + bare.fov_swing, bare.turn); + return bare.turn > 100.0 && held.turn < 5.0 && held.nearest > held.before * 0.95 && + held.farthest < held.before * 1.05 && + sprung.farthest - sprung.nearest > held.farthest - held.nearest && + std::fabs(held.settled - held.before) < 8.0 && held.aim_swing < 40.0 && held.fov_swing < 1.0; } // A hop off the wall is not a landing: no dip and no shake during a hit. @@ -644,6 +663,173 @@ bool RecentringEasesIn() { eased_recenter_sharpness(3.0F, 1.21F, 1.2F, 0.0F) == 3.0F; } +// A wall across the boom behind the racer: the vertical plane z = wall_z +// (the camera side is lower z), solid up to height top, and optionally a +// ceiling at the same height over everything. +struct Wall { + float wall_z = -40.0F; + float top = 1.0e6F; + bool ceiling = false; + bool present = true; // set by stand_by_wall +}; + +int wall_raycast(void* user, MCVec3 from, MCVec3 to, float radius, float* hit) { + const Wall& wall = *static_cast(user); + if (!wall.present) return 0; + float best = 2.0F; + const float plane = wall.wall_z + radius; // where the probe sphere touches it + if (to.z < plane && from.z > plane) { + const float t = (from.z - plane) / (from.z - to.z); + if (from.y + (to.y - from.y) * t < wall.top + radius) best = t; + } + const float roof = wall.top - radius; + if (wall.ceiling && to.y > roof && from.y < roof) best = std::min(best, (roof - from.y) / (to.y - from.y)); + if (best > 1.0F) return 0; + *hit = best; + return 1; +} + +struct WallRun { + double closest = 1.0e9; // nearest the eye came to the pivot, units + MCOutput out{}; + MCState state{}; +}; + +// A racer at rest facing +z (the camera behind it at lower z) for `seconds`, +// with the wall present from the first step and gone after `gone_after`. +WallRun stand_by_wall(Wall wall, float seconds, float gone_after = 1.0e9F) { + const MCParams p = dkr_params(0U); + WallRun run; + const float dt = 1.0F / 30.0F; + const int steps = static_cast(seconds / dt); + for (int i = 0; i < steps; ++i) { + wall.present = i * dt < gone_after; + MCInput in{}; + in.grounded = 1; + ModernCam_Update(&run.state, &p, &in, dt, wall_raycast, &wall, &run.out); + const MCVec3 a = run.state.anchor; + const double dx = run.out.eye.x - a.x, dy = run.out.eye.y - (a.y + p.targetHeight * p.unitScale), + dz = run.out.eye.z - a.z; + run.closest = std::min(run.closest, std::sqrt(dx * dx + dy * dy + dz * dz)); + } + return run; +} + +// A wall right behind the racer once folded the boom to 5% of its length, +// inside the racer: the screen filled with Diddy's cap. The eye now stays at +// least collisionMinDist from the pivot and rises over a wall it can clear. +bool WallNeverPutsTheEyeInTheRacer() { + const MCParams p = dkr_params(0U); + const double floor = p.collisionMinDist * p.unitScale; + Wall low; + low.top = 60.0F; + const WallRun over = stand_by_wall(low, 2.0F); + Wall tall; + const WallRun backed = stand_by_wall(tall, 2.0F); + Wall box; + box.top = 40.0F; + box.ceiling = true; + const WallRun boxed = stand_by_wall(box, 2.0F); + std::printf("[test][modern-camera] wall: low %.0f units from the pivot (eye y %.0f, lift %.2f), " + "tall %.0f (lift %.2f), boxed in %.0f; floor %.0f\n", + over.closest, over.out.eye.y, over.state.lift, backed.closest, backed.state.lift, boxed.closest, + floor); + // Never inside the racer, whatever the wall. + if (over.closest < floor * 0.99 || backed.closest < floor * 0.99 || boxed.closest < floor * 0.99) return false; + // Over a low wall the camera rises above it and stands clear. + if (!(over.state.lift > 0.0F) || !(over.out.eye.y > low.top) || over.state.collisionDist < 0.9F) return false; + // Backed against a tall one it rises as far as it may and looks down. + if (!(backed.state.lift > 0.0F) || !(backed.out.pitch < -0.5F)) return false; + return true; +} + +// A wall that suddenly cuts the boom (the racer turns along it) once pulled +// the eye in by metres in one frame and let it slide back out: the "back and +// forth" of a hit by a wall. It now slides in too, quickly. +bool WallCutSlides() { + const MCParams p = dkr_params(0U); + MCState s{}; + MCOutput out{}; + Wall wall; + wall.wall_z = -80.0F; + const float dt = 1.0F / 30.0F; + double previous = 0.0, biggest = 0.0, before = 0.0, after = 0.0; + for (int i = 0; i < 60; ++i) { + wall.present = i >= 30; + MCInput in{}; + in.grounded = 1; + ModernCam_Update(&s, &p, &in, dt, wall_raycast, &wall, &out); + if (i == 29) before = out.eye.z; + if (i >= 30) biggest = std::max(biggest, std::fabs(out.eye.z - previous)); + previous = out.eye.z; + } + after = out.eye.z; + const double total = std::fabs(after - before); + std::printf("[test][modern-camera] wall cuts the boom: eye comes %.0f units in, at most %.0f in one frame\n", total, + biggest); + return total > 80.0 && biggest < total * 0.4 && after > wall.wall_z; +} + +// A racer that bounces off a wall often takes the wall out from between +// itself and the camera. The boom used to stretch back out at once, so the +// camera came in for the wall and backed off during the hit. Held, it stays +// in until the hit eases out, then returns. +bool HitKeepsTheBoomIn() { + const MCParams p = dkr_params(0U); + MCState s{}; + MCOutput out{}; + Wall wall; + wall.wall_z = -80.0F; + const float dt = 1.0F / 30.0F; + float during = 0.0F, at_hit = 0.0F; + for (int i = 0; i < 120; ++i) { + wall.present = i >= 30 && i < 60; // the wall leaves as the hit starts + MCInput in{}; + in.grounded = 1; + in.hold = i >= 60 && i < 75 ? 1.0F : 0.0F; + ModernCam_Update(&s, &p, &in, dt, wall_raycast, &wall, &out); + if (i == 59) at_hit = s.collisionDist; + if (i == 74) during = s.collisionDist; + } + std::printf("[test][modern-camera] wall gone during a hit: boom %.2f at the hit, %.2f at its end, %.2f after\n", + at_hit, during, s.collisionDist); + return at_hit < 0.6F && std::fabs(during - at_hit) < 0.01F && s.collisionDist > 0.95F; +} + +// Once the wall is gone the camera settles back to its usual height. +bool WallLiftReturns() { + const WallRun run = stand_by_wall(Wall{}, 4.0F, 1.0F); + const WallRun open = stand_by_wall(Wall{}, 4.0F, 0.0F); + std::printf("[test][modern-camera] wall gone: lift %.3f, eye y %.1f vs %.1f with no wall\n", run.state.lift, + run.out.eye.y, open.out.eye.y); + return run.state.lift == 0.0F && open.state.lift == 0.0F && std::fabs(run.out.eye.y - open.out.eye.y) < 1.0F; +} + +// A hovercraft that hits a bank rides 280 units up it in a second, on the +// ground, nose up. The camera once trailed up to 88 units below it, tilted +// with the bank, looking up at its underside. On the ground it now keeps +// within riseLag of a climbing racer and tilts at most slopeMax. +bool ClimbingKeepsTheCameraAbove() { + const MCParams p = dkr_params(0U); + MCState s{}; + MCOutput out{}; + const float dt = 1.0F / 30.0F; + double lowest = 1.0e9; + for (int i = 0; i < 60; ++i) { + const bool climbing = i >= 15 && i < 45; + MCInput in{}; + in.pos = MCVec3{0.0F, climbing ? (i - 15) * 280.0F / 30.0F : (i < 15 ? 0.0F : 280.0F), 300.0F * dt * i}; + in.vel = MCVec3{0.0F, climbing ? 280.0F : 0.0F, 300.0F}; + in.slopePitch = climbing ? 0.9F : 0.0F; + in.grounded = 1; + ModernCam_Update(&s, &p, &in, dt, nullptr, nullptr, &out); + lowest = std::min(lowest, static_cast(out.eye.y - in.pos.y)); + } + std::printf("[test][modern-camera] climbing a bank: eye at most %.0f units below the racer, tilt %.2f\n", + -lowest, s.slope); + return lowest > -p.riseLag * p.unitScale && std::fabs(s.slope) <= p.slopeMax + 1e-6F; +} + bool TuningScalesWithLayout() { Tuning tuning = default_tuning(); tuning.params.distMin = 4.0F; @@ -684,6 +870,11 @@ int main() { {"hit reaction scale", HitReactionScale}, {"knocked nose", KnockedNoseTurnsOnScreen}, {"second hit", SecondHitDoesNotGoStale}, + {"wall never puts the eye in the racer", WallNeverPutsTheEyeInTheRacer}, + {"wall lift returns", WallLiftReturns}, + {"wall cut slides", WallCutSlides}, + {"hit keeps the boom in", HitKeepsTheBoomIn}, + {"climbing keeps the camera above", ClimbingKeepsTheCameraAbove}, }; for (const auto& test : tests) { if (!test.run()) { From 85b4f84028da5390b0757c792f0939e881a2a19e Mon Sep 17 00:00:00 2001 From: leite-matheus <78598648+leite-matheus@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:15:23 -0300 Subject: [PATCH 04/11] Enhanced water: textured caustics and plane/hovercraft low-pass spray --- patches/enhanced-water/dkr-caustics.patch | 234 +++++++++++ patches/enhanced-water/dkr-lowpass.patch | 370 ++++++++++++++++++ .../enhanced-water/native/WaterCaustics.png | Bin 0 -> 96610 bytes .../native/WaterProjectionProbeCS.hlsl | 16 +- runtime-recomp/cmake/EnhancedWater.cmake | 15 +- .../src/game/enhanced_water_rt64.hpp | 30 +- .../tests/enhanced_water_ray_gpu_tests.cpp | 33 +- scripts/compose-water-caustics.py | 193 +++++++++ scripts/compose-water-lowpass.py | 312 +++++++++++++++ 9 files changed, 1187 insertions(+), 16 deletions(-) create mode 100644 patches/enhanced-water/dkr-caustics.patch create mode 100644 patches/enhanced-water/dkr-lowpass.patch create mode 100644 patches/enhanced-water/native/WaterCaustics.png create mode 100644 scripts/compose-water-caustics.py create mode 100644 scripts/compose-water-lowpass.py diff --git a/patches/enhanced-water/dkr-caustics.patch b/patches/enhanced-water/dkr-caustics.patch new file mode 100644 index 00000000..4af1d041 --- /dev/null +++ b/patches/enhanced-water/dkr-caustics.patch @@ -0,0 +1,234 @@ +diff --git a/src/render/rt64_water_renderer.h b/src/render/rt64_water_renderer.h +--- a/src/render/rt64_water_renderer.h ++++ b/src/render/rt64_water_renderer.h +@@ -88,8 +88,8 @@ + } + }; + struct WaterDescriptorSet : RenderDescriptorSetBase { +- uint32_t draws, sceneColor, sceneDepth, currentField, previousField, originalColor; +- explicit WaterDescriptorSet(RenderDevice *device = nullptr) { ++ uint32_t draws, sceneColor, sceneDepth, currentField, previousField, originalColor, caustics; ++ explicit WaterDescriptorSet(RenderDevice *device = nullptr, const RenderSampler *causticSampler = nullptr) { + builder.begin(); + draws = builder.addStructuredBuffer(1); + sceneColor = builder.addTexture(2); +@@ -97,6 +97,10 @@ + currentField = builder.addTexture(4); + previousField = builder.addTexture(5); + originalColor = builder.addTexture(6); ++ caustics = builder.addTexture(7); ++ // Immutable samplers go last: D3D12 drops them from the root table but ++ // still counts them in the set, so any later range would read empty. ++ builder.addImmutableSampler(8, causticSampler); + builder.end(); + if (device) create(device); + } +@@ -106,6 +110,15 @@ + struct WaterRenderer { + std::vector draws; + BufferPair drawBuffer; ++ // Declared before every descriptor set: the immutable sampler and the ++ // texture must outlive the sets that sample them. ++ std::unique_ptr causticSampler; ++ std::unique_ptr causticTexture; ++ std::unique_ptr causticView; ++ std::unique_ptr causticUpload; ++ std::vector causticOffsets; ++ uint32_t causticSize=0; ++ bool causticPending=false; + std::unique_ptr descriptorSet; + std::unique_ptr layout; + std::unique_ptr vertexShader; +@@ -217,6 +230,7 @@ + uint64_t allocationBytes() const; + void prepareField(RenderWorker *worker); + void recordField(RenderWorker *worker); ++ void recordCaustics(RenderWorker *worker); + void stepSpray(const interop::WaterMaterial &material, float previousTime, float nextTime, bool reset); + void beginSurface(RenderWorker *worker,uint32_t drawIndex); + void beginColor(RenderWorker *worker,uint32_t drawIndex); +diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer.cpp +--- a/src/render/rt64_water_renderer.cpp ++++ b/src/render/rt64_water_renderer.cpp +@@ -6,6 +6,8 @@ + #include + #include + #include ++#include "stb/stb_image.h" ++#include "textures/WaterCaustics.png.h" + #include "shaders/WaterVS.hlsl.spirv.h" + #include "shaders/WaterPS.hlsl.spirv.h" + #include "shaders/WaterHardwarePS.hlsl.spirv.h" +@@ -90,7 +92,53 @@ + WaterRenderer::WaterRenderer(RenderDevice *device, RenderShaderFormat format, bool hdr) : usesHDR(hdr) { + if (std::getenv("WR64_TEST_WATER_SHADER_FAILURE")) throw std::runtime_error("injected water shader failure"); + if (std::getenv("DKRR_WATER_PROFILE") && device->getCapabilities().queryPools) profilePool = device->createQueryPool(128); +- descriptorSet = std::make_unique(device); ++ // The caustic pattern is an authored grayscale PNG embedded at build time. ++ // Box-filtered mips are staged here and uploaded once by recordCaustics. ++ RenderSamplerDesc causticDesc; ++ causticDesc.maxAnisotropy=getDefaultSamplerAnisotropy(); ++ causticDesc.anisotropyEnabled=causticDesc.maxAnisotropy>1; ++ causticSampler=checkedWaterResource(device->createSampler(causticDesc)); ++ int causticWidth=0,causticHeight=0,causticChannels=0; ++ stbi_uc *causticPixels=stbi_load_from_memory(reinterpret_cast(WaterCausticsPNG),int(sizeof(WaterCausticsPNG)), ++ &causticWidth,&causticHeight,&causticChannels,1); ++ if (!causticPixels || causticWidth<1 || causticWidth!=causticHeight || (causticWidth&(causticWidth-1))) { ++ stbi_image_free(causticPixels); ++ throw std::runtime_error("water caustic texture is invalid; retaining Original"); ++ } ++ causticSize=uint32_t(causticWidth); ++ std::vector> causticMips{std::vector(causticPixels,causticPixels+size_t(causticSize)*causticSize)}; ++ stbi_image_free(causticPixels); ++ for (uint32_t size=causticSize;size>1;size/=2) { ++ const auto &source=causticMips.back(); ++ const uint32_t half=size/2; ++ std::vector next(size_t(half)*half); ++ for (uint32_t y=0;y>level,1u); ++ causticBytes+=((size+255)&~255u)*size; ++ } ++ causticUpload=checkedWaterResource(device->createBuffer(RenderBufferDesc::UploadBuffer(causticBytes))); ++ auto *causticStaging=static_cast(causticUpload->map()); ++ std::memset(causticStaging,0,causticBytes); ++ for (uint32_t level=0;level>level,1u), pitch=(size+255)&~255u; ++ for (uint32_t y=0;yunmap(); ++ causticTexture=checkedWaterResource(device->createTexture(RenderTextureDesc::Texture2D(causticSize,causticSize,uint32_t(causticMips.size()),RenderFormat::R8_UNORM))); ++ causticView=checkedWaterResource(causticTexture->createTextureView(RenderTextureViewDesc::Texture2D(RenderFormat::R8_UNORM))); ++ causticTexture->setName("Water authored caustic pattern"); ++ causticPending=true; ++ descriptorSet = std::make_unique(device,causticSampler.get()); + RenderPipelineLayoutBuilder builder; + builder.begin(false, true); // Water vertices use the input assembler on D3D12. + builder.addPushConstant(0, 0, sizeof(interop::WaterPushConstants), RenderShaderStageFlag::VERTEX | RenderShaderStageFlag::PIXEL); +@@ -195,6 +243,8 @@ + wireIndices.clear(); wireStarts.clear(); + profileKinds.clear(); + for (auto &scene : scenes) { scene.captured=false; scene.surfaceCleared=false; } ++ // The workload that recorded the caustic upload has completed. ++ if (!causticPending) causticUpload.reset(); + } + + uint32_t WaterRenderer::prepareScene(RenderWorker *worker, RenderTarget *color, RenderTarget *depth, uint64_t key, bool classic, bool selfDepth) { +@@ -265,7 +315,7 @@ + scene.color->setName("Water immutable scene color"); + scene.depth->setName("Water immutable scene depth"); + scene.captureSet = std::make_unique(worker->device); +- scene.materialSet = std::make_unique(worker->device); ++ scene.materialSet = std::make_unique(worker->device,causticSampler.get()); + } + if (classic && (!scene.originalColor || scene.originalSamples!=color->multisampling.sampleCount)) { + scene.originalSet.reset(); scene.originalColorView.reset(); scene.originalColor.reset(); +@@ -330,6 +380,7 @@ + classic ? scene.originalColorView.get() : scene.colorView.get()); + ms->setTexture(ms->sceneColor,scene.color.get(),RenderTextureLayout::SHADER_READ,scene.colorView.get()); + ms->setTexture(ms->sceneDepth,scene.depth.get(),RenderTextureLayout::SHADER_READ,scene.depthView.get()); ++ ms->setTexture(ms->caustics,causticTexture.get(),RenderTextureLayout::SHADER_READ,causticView.get()); + sceneKeys.emplace(sceneKey,index); + return index; + } catch(...) { +@@ -713,6 +764,18 @@ + }); + endProfile(worker,token); + ++} ++ ++void WaterRenderer::recordCaustics(RenderWorker *worker) { ++ if (!causticPending) return; ++ worker->commandList->barriers(RenderBarrierStage::COPY,RenderTextureBarrier(causticTexture.get(),RenderTextureLayout::COPY_DEST)); ++ for (uint32_t level=0;level>level,1u); ++ worker->commandList->copyTextureRegion(RenderTextureCopyLocation::Subresource(causticTexture.get(),level), ++ RenderTextureCopyLocation::PlacedFootprint(causticUpload.get(),RenderFormat::R8_UNORM,size,size,1,(size+255)&~255u,causticOffsets[level])); ++ } ++ worker->commandList->barriers(RenderBarrierStage::GRAPHICS,RenderTextureBarrier(causticTexture.get(),RenderTextureLayout::SHADER_READ)); ++ causticPending=false; + } + + uint32_t WaterRenderer::beginProfile(RenderWorker *worker, uint32_t kind) { +diff --git a/src/render/rt64_framebuffer_renderer.cpp b/src/render/rt64_framebuffer_renderer.cpp +--- a/src/render/rt64_framebuffer_renderer.cpp ++++ b/src/render/rt64_framebuffer_renderer.cpp +@@ -1332,6 +1332,7 @@ + if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->rays.record(worker); + if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordNormals(worker, *outputBuffers); + if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordField(worker); ++ if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordCaustics(worker); + + if (vertexProcessor != nullptr) { + vertexProcessor->recordCommandList(worker, shaderLibrary, outputBuffers); +diff --git a/src/shaders/WaterPS.hlsl b/src/shaders/WaterPS.hlsl +--- a/src/shaders/WaterPS.hlsl ++++ b/src/shaders/WaterPS.hlsl +@@ -6,6 +6,8 @@ + Texture2D currentField : register(t4); + Texture2D previousField : register(t5); + Texture2D originalColor : register(t6); ++Texture2D causticTexture : register(t7); ++SamplerState causticSampler : register(s8); + + static const float PI = 3.14159265359; + +@@ -316,16 +318,18 @@ + float refractedThickness = opticalDepth(refractedPixel,position.z,worldPosition,p); + // Reject foreground silhouettes at the proposed sample before distorting. + if (refractedThickness < 0) { refractedPixel = position.xy; refractedThickness = thickness; } +- // Derivatives must be evaluated for the whole pixel quad, before the +- // depth-validity branch. A branch crossing a shoreline otherwise leaves +- // caustic receiver normals/filter widths undefined on some backends. +- float bottomDepth=sceneDepth.Load(int3(int2(boundPixel(refractedPixel,p)),0)); +- float3 bottom=waterWorldFromPixel(refractedPixel,bottomDepth,p); +- float3 bottomNormal=cross(ddx(bottom),ddy(bottom)); +- bottomNormal*=rsqrt(max(dot(bottomNormal,bottomNormal),0.000001)); +- if (bottomNormal.y<0) bottomNormal=-bottomNormal; +- float2 causticUV=bottom.xz*0.035; +- float causticFilter=1-smoothstep(0.25,1.5,length(fwidth(causticUV))); ++ // Caustics follow the reference water workflow: one authored pattern read ++ // twice on the water plane, panned in opposite directions, the second ++ // layer rescaled so the two never align. Mipmaps keep distant cells from ++ // breaking into stair-stepped lines. Sample before the depth-validity ++ // branch: a branch crossing a shoreline leaves derivatives undefined. ++ float2 causticUV=worldPosition.xz/130+ripples*0.6; ++ float caustic=causticTexture.Sample(causticSampler,causticUV+float2(-0.05,0.05)*p.camera.w) ++ +causticTexture.Sample(causticSampler,(causticUV+float2(0.05,-0.05)*p.camera.w)*0.66); ++ // Vertical water column under this pixel. Unlike the view-ray thickness it ++ // does not grow at grazing angles. Negative without a submerged surface. ++ float waterDepth=thickness>=0 ? max(worldPosition.y-waterWorldFromPixel(position.xy, ++ sceneDepth.Load(int3(int2(position.xy),0)),p).y,0) : -1; + if (thickness >= 0 && refractedThickness >= 0) { + float3 transmission = exp(-m.deepColor.a * float3(1.8,0.55,0.30) * refractedThickness); + // Course geometry often ends a short distance below the shoreline. +@@ -339,15 +343,12 @@ + // Uncovered taps transition to the water body, not an unrelated + // background color. Keep fractional coverage for a soft boundary. + transmission*=receiverColor.a; +- // Stable world-space light focusing, confined to an actual shallow +- // submerged receiver. Dark source lighting and steep walls reject it. +- float phaseA=causticUV.x+sin(causticUV.y*1.3+p.camera.w*0.65); +- float phaseB=causticUV.y-sin(causticUV.x*1.1-p.camera.w*0.5); +- float focusing=pow(saturate(1-abs(sin(phaseA)+sin(phaseB)*0.55)),12); +- float receiver=smoothstep(0.2,0.65,bottomNormal.y)*saturate(dot(bottomNormal,L)); ++ // Focused light only in shallow water and near the camera, and never ++ // on receivers whose source lighting is dark. ++ float receiver=smoothstep(0,2,waterDepth)*(1-smoothstep(0,60,waterDepth)); ++ receiver*=1-smoothstep(900,2200,length(p.camera.xyz-worldPosition)); + receiver*=smoothstep(0.015,0.08,dot(bottomColor,float3(0.2126,0.7152,0.0722))); +- receiver*=bottom.y0) return 1-smoothstep(WATER_LOWPASS_PLANE_FULL,WATER_LOWPASS_PLANE_CEILING,air); ++ if (air<0) return WATER_LOWPASS_HOVER_SCALE*(1-smoothstep(WATER_LOWPASS_HOVER_FULL,WATER_LOWPASS_HOVER_CEILING,-air)); ++ return 0; ++} ++#endif +diff --git a/src/render/rt64_water_renderer.h b/src/render/rt64_water_renderer.h +--- a/src/render/rt64_water_renderer.h ++++ b/src/render/rt64_water_renderer.h +@@ -122,6 +122,7 @@ + BufferPair particleBuffer; + std::array sprayDistance{}; + std::array sprayHeading{}; ++ std::array lowPassDistance{}; + uint32_t spraySeed=1; + std::map> pipelines; + RenderVertexBufferView worldView; +diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer.cpp +--- a/src/render/rt64_water_renderer.cpp ++++ b/src/render/rt64_water_renderer.cpp +@@ -457,9 +457,20 @@ + worker->commandList->setFramebuffer(scene.waterColorFramebuffer.get()); + } + ++// Matches waterLowPassStrength in rt64_water_params.h. ++static float lowPassStrength(float air) { ++ const auto fade=[](float full,float ceiling,float clearance) { ++ const float t=std::clamp((clearance-full)/(ceiling-full),0.0f,1.0f); ++ return 1-t*t*(3-2*t); ++ }; ++ if (air>0) return fade(WATER_LOWPASS_PLANE_FULL,WATER_LOWPASS_PLANE_CEILING,air); ++ if (air<0) return WATER_LOWPASS_HOVER_SCALE*fade(WATER_LOWPASS_HOVER_FULL,WATER_LOWPASS_HOVER_CEILING,-air); ++ return 0; ++} ++ + void WaterRenderer::stepSpray(const interop::WaterMaterial &m,float fromTime,float toTime,bool reset) { + if (reset || m.identity.x<2 || m.effects.x<0.5f) { +- particles.clear(); sprayDistance.fill(0); sprayHeading.fill({0,0,0,0}); ++ particles.clear(); sprayDistance.fill(0); sprayHeading.fill({0,0,0,0}); lowPassDistance.fill(0); + spraySeed=0x57A064u^uint32_t(m.identity.w); + return; + } +@@ -495,6 +506,45 @@ + particles.push_back(p); + } + } ++ // Low passes lift spray from the liquid beneath the craft, not from its ++ // hull: outward curtains (kind 2) and slowly settling mist (kind 1). ++ for (uint32_t i=0;i0;}); ++ // A low pass over the liquid keeps the field alive without hull contact. ++ bool lowPass=false; ++ for (uint32_t i=0;i=0 && m.craftAir[i/4][i%4]!=0; ++ const bool active=horizontal!=draws.end() && (lowPass || ++ std::any_of(std::begin(m.craftCurrent),std::end(m.craftCurrent),[](const auto& c){return c.w>0;})); + const uint32_t resolution=water_work::fieldResolution(active,bool(fieldTextures[0]),fieldResolution,uint32_t(m.identity.x)); + constexpr float extent=4096, dt=1.0f/60; + if (fieldResolution!=resolution) { +diff --git a/src/shaders/WaterFieldCS.hlsl b/src/shaders/WaterFieldCS.hlsl +--- a/src/shaders/WaterFieldCS.hlsl ++++ b/src/shaders/WaterFieldCS.hlsl +@@ -22,6 +22,7 @@ + groupshared float4 craftPath[WATER_CRAFT_COUNT]; + groupshared float4 craftMotion[WATER_CRAFT_COUNT]; + groupshared float4 craftResponse[WATER_CRAFT_COUNT]; ++groupshared float4 craftWash[WATER_CRAFT_COUNT]; + [numthreads(8,8,1)] + void CSMain(uint2 pixel : SV_DispatchThreadID, uint lane : SV_GroupIndex) { + // Zero-duration reset has no history, swept motion, or shoreline growth. +@@ -48,6 +49,11 @@ + craftMotion[lane]=float4(heading,distance/interval,min(length(to-from)/cell,2)*saturate(current.w*3)); + bool landed=!params.reset && old.w==0 && current.w>0 && params.simulation.y=m.animation.y; + craftResponse[lane]=float4(saturate((old.y-current.y)/interval/180),landed ? 1 : 0,current.w,0); ++ // Low pass: a footprint trailing beneath the craft, wider when higher. ++ float air=m.craftAir[lane/4][lane%4]; ++ float clearance=abs(air); ++ float wash=current.w>=0 ? waterLowPassStrength(air)*saturate(distance/interval/WATER_LOWPASS_FULL_SPEED) : 0; ++ craftWash[lane]=float4(to-heading*(10+clearance*0.15),20+clearance*0.3,wash*min(length(to-from)/cell,2)); + } + GroupMemoryBarrierWithGroupSync(); + if (pixel.x>=params.resolution*WATER_CRAFT_COUNT || pixel.y>=params.resolution) return; +@@ -109,6 +115,19 @@ + foam += ring*0.65; + velocity += ring*0.7; + } ++ float4 wash=craftWash[i]; ++ if (wash.w>0) { ++ float2 offset=world-wash.xy; ++ float r=wash.z, behind=max(-dot(offset,heading),0); ++ float lateral=abs(dot(offset,float2(-heading.y,heading.x))); ++ // The moving pressure dent radiates its own waves; curtains spread ++ // wider than the hull wake; a churned trail follows the path. ++ float dent=exp(-dot(offset,offset)/(r*r)); ++ float curtain=exp(-pow((lateral-behind*0.55-r*0.35)/(6+r*0.25),2))*smoothstep(0,r*0.5,behind)*(1-smoothstep(r*2.5,r*4.5,behind)); ++ float churn=exp(-pow(d/(r*0.5),2)); ++ foam += (churn*0.035+curtain*0.03)*wash.w; ++ velocity += (curtain*0.05-dent*0.12)*wash.w; ++ } + } + if (m.craftCurrent[tile].w<0) { height=0;velocity=0;foam=0;shore=0; } + nextField[pixel]=float4(clamp(height,-1,1),clamp(velocity,-4,4),saturate(foam),saturate(shore)); +diff --git a/src/shaders/WaterSprayVS.hlsl b/src/shaders/WaterSprayVS.hlsl +--- a/src/shaders/WaterSprayVS.hlsl ++++ b/src/shaders/WaterSprayVS.hlsl +@@ -5,7 +5,7 @@ + + void VSMain(uint vertex : SV_VertexID, uint instance : SV_InstanceID, + out float4 position : SV_POSITION, out float2 uv : TEXCOORD0, +- out float3 world : TEXCOORD1, out float2 fade : TEXCOORD2) { ++ out float3 world : TEXCOORD1, out float4 fade : TEXCOORD2) { + // Return a finite, degenerate offscreen primitive for invisible particles. + // Alpha zero alone cannot make invalid projected positions safe. + position=float4(2,2,2,1); uv=0; world=0; fade=0; +@@ -13,11 +13,16 @@ + WaterSprayParticle particle=particles[instance]; + float age=p.camera.w-particle.origin.w; + if (age<0 || age>=particle.velocity.w || particle.velocity.w<=0) return; +- float drag=1.5, response=(1-exp(-drag*age))/drag; ++ // Kind 0 is hull spray, 1 low-pass mist, 2 low-pass curtain droplets. ++ float kind=particle.appearance.w; ++ bool mist=kind>0.5 && kind<1.5, streak=kind>1.5; ++ float life=saturate(age/particle.velocity.w); ++ float drag=mist ? 2.6 : 1.5, gravity=mist ? 12 : 140, response=(1-exp(-drag*age))/drag; + world=particle.origin.xyz+particle.velocity.xyz*response; +- world.y-=140*(age-response)/drag; ++ world.y-=gravity*(age-response)/drag; ++ float size=particle.appearance.x*(mist ? 1+life*1.2 : 1); + float4 clip=mul(p.viewProjection,float4(world,1)); +- float4 top=mul(p.viewProjection,float4(world+float3(0,particle.appearance.x,0),1)); ++ float4 top=mul(p.viewProjection,float4(world+float3(0,size,0),1)); + // Both points must be in front of the eye before either perspective divide. + // A top edge crossing the eye plane used to create an enormous white quad. + if (!all(isfinite(clip)) || !all(isfinite(top)) || clip.w<=0.01 || top.w<=0.01) return; +@@ -29,11 +34,32 @@ + const float2 corners[6]={float2(-1,-1),float2(1,-1),float2(-1,1),float2(-1,1),float2(1,-1),float2(1,1)}; + uv=corners[vertex]; + position=float4(xy*clip.w,screen.z*clip.w,clip.w); +- float radius=clamp(projectedRadius,0.0005,0.035); +- position.xy+=uv*radius*float2(p.screen.y/p.screen.x,1)*clip.w; +- float life=saturate(age/particle.velocity.w); ++ // Streaks keep a minimum width so fine droplets stay visible. ++ float radius=clamp(projectedRadius,streak ? 0.0022 : 0.0005,mist ? 0.24 : streak ? 0.12 : 0.035); ++ float2 aspect=float2(p.screen.y/p.screen.x,1); ++ float2 offset=uv*radius*aspect; ++ float energy=1; ++ if (streak) { ++ // Stretch curtain droplets along their recent screen motion. ++ float decay=exp(-drag*age); ++ float3 velocity=particle.velocity.xyz*decay-float3(0,gravity*(1-decay)/drag,0); ++ float4 tail=mul(p.viewProjection,float4(world-velocity*0.09,1)); ++ if (all(isfinite(tail)) && tail.w>0.01) { ++ float3 tailScreen=tail.xyz/tail.w*float3(1,-1,1)*p.viewportScale.xyz+p.viewportTranslate.xyz; ++ float2 tailXY=(tailScreen.xy/p.screen.xy*float2(2,-2)+float2(-1,1))*p.screenTransform.xy+p.screenTransform.zw; ++ float2 trail=(xy-tailXY)/aspect; ++ float span=length(trail); ++ if (all(isfinite(trail)) && span>radius*0.5) { ++ float2 along=trail/span, across=float2(-along.y,along.x); ++ span=min(span,0.2); ++ offset=(across*uv.x*radius+along*(uv.y*(span*0.5+radius)-span*0.5))*aspect; ++ energy=pow(saturate(2*radius/(span+2*radius)),0.3); ++ } ++ } ++ } ++ position.xy+=offset*clip.w; + float distance=length(world-p.camera.xyz); +- float nearFade=smoothstep(particle.appearance.x*4,particle.appearance.x*12,distance); +- float sizeFade=1-smoothstep(0.012,0.035,projectedRadius); +- fade=float2(particle.appearance.y*smoothstep(0,0.07,age)*(1-smoothstep(0.55,1,life))*nearFade*sizeFade,distance); ++ float nearFade=mist ? smoothstep(size*1.5,size*4,distance) : smoothstep(particle.appearance.x*4,particle.appearance.x*12,distance); ++ float sizeFade=mist ? 1-smoothstep(0.14,0.24,projectedRadius) : streak ? 1-smoothstep(0.07,0.12,projectedRadius) : 1-smoothstep(0.012,0.035,projectedRadius); ++ fade=float4(particle.appearance.y*smoothstep(0,0.07,age)*(1-smoothstep(0.55,1,life))*nearFade*sizeFade*energy,distance,kind,particle.appearance.z); + } +diff --git a/src/shaders/WaterSprayPS.hlsl b/src/shaders/WaterSprayPS.hlsl +--- a/src/shaders/WaterSprayPS.hlsl ++++ b/src/shaders/WaterSprayPS.hlsl +@@ -8,10 +8,14 @@ + Texture2D waterDepth : register(t4); + #endif + float4 PSMain(float4 position : SV_POSITION, float2 uv : TEXCOORD0, +- float3 world : TEXCOORD1, float2 fade : TEXCOORD2) : SV_TARGET0 { ++ float3 world : TEXCOORD1, float4 fade : TEXCOORD2) : SV_TARGET0 { + WaterDrawParams p=waterDraws[gConstants.drawIndex]; + float radius=dot(uv,uv); +- float alpha=(1-smoothstep(0.10,1,radius))*fade.x; ++ bool mist=fade.z>0.5 && fade.z<1.5; ++ // Streaks are brightest at the head (uv.y=+1) and fade toward the tail. ++ float streak=(1-smoothstep(0.15,1,abs(uv.x)))*smoothstep(-1,0.3,uv.y)*(1-smoothstep(0.75,1,uv.y)); ++ float alpha=(mist ? exp(-radius*2.2)*(1-smoothstep(0.6,1,radius)) : fade.z>1.5 ? streak : 1-smoothstep(0.10,1,radius))*fade.x; ++ float soft=mist ? 40 : 12; + int2 pixel=int2(clamp(position.xy,p.viewBounds.xy,p.viewBounds.zw-1)); + float water=0; + #ifdef SPRAY_MSAA +@@ -23,13 +27,21 @@ + #else + water=waterDepth.Load(int3(pixel,0)); + #endif +- if (water>0) alpha*=saturate((water-fade.y)/12); ++ if (water>0) alpha*=saturate((water-fade.y)/soft); + float depth=sceneDepth.Load(int3(pixel,0)); + if (depth<0.9999) { + float3 background=waterWorldFromPixel(position.xy,depth,p); +- alpha*=saturate((length(background-p.camera.xyz)-fade.y)/12); ++ alpha*=saturate((length(background-p.camera.xyz)-fade.y)/soft); + } + float3 light=(p.material.skyColor.rgb*0.3+p.material.sunColor.rgb*0.7)*p.material.sunColor.a; ++ if (fade.z>0.5) { ++ // Low-pass spray is lit white water, glowing faintly against the sun. ++ light=max(light,float3(0.62,0.66,0.68)*p.material.sunColor.a*2); ++ float scatter=pow(saturate(dot(normalize(world-p.camera.xyz),normalize(p.material.sunDirection.xyz))),6); ++ light+=p.material.sunColor.rgb*scatter*(mist ? 0.55 : 0.35); ++ // A few droplets catch the sun as glints. ++ if (fade.z>1.5 && fade.w>0.85) light+=p.material.sunColor.rgb*(0.25+scatter); ++ } + // Match the course haze without introducing a second exposure system. + float fog=p.fogColor.a*saturate((fade.y-1200)/5000); + light=lerp(light,p.fogColor.rgb,fog); +diff --git a/src/shaders/WaterPS.hlsl b/src/shaders/WaterPS.hlsl +--- a/src/shaders/WaterPS.hlsl ++++ b/src/shaders/WaterPS.hlsl +@@ -261,6 +261,38 @@ + float determinant=dx.x*dy.y-dx.y*dy.x; + float2 wakeGradient=float2(ddx(wake.x)*dy.y-ddy(wake.x)*dx.y,ddy(wake.x)*dx.x-ddx(wake.x)*dy.x)/max(abs(determinant),0.001)*sign(determinant); + ripples += clamp(wakeGradient*8,-0.10,0.10); ++ // Low pass: downwash and hovercraft skirts roughen and whiten a teardrop ++ // of liquid beneath and behind each craft. Analytic, so it never lags. ++ float churn=0,churnFoam=0; ++ float2 churnHeading=0; ++ { ++ float interval=max(m.animation.y-m.animation.x,0.00001); ++ float f=saturate((p.camera.w-m.animation.x)/interval); ++ [loop] for (uint i=0;i0 ? radius*0.7 : radius*3.5; ++ float footprint=exp(-pow(lateral/radius,2)-pow(ahead/extent,2))*wash; ++ if (footprint>churn) { churn=footprint; churnHeading=heading; } ++ } ++ } ++ if (churn>0.001) { ++ // World-anchored streaks along the heading: the craft sweeps over them. ++ float2 across=float2(-churnHeading.y,churnHeading.x); ++ float2 streakAt=float2(dot(worldPosition.xz,across)*0.16,dot(worldPosition.xz,churnHeading)*0.012); ++ float s0=foamNoise(streakAt); ++ float2 slope=across*(foamNoise(streakAt+float2(0.35,0))-s0)*0.5+churnHeading*(foamNoise(streakAt+float2(0,0.03))-s0)*0.2; ++ ripples += clamp(slope*churn*0.6,-0.12,0.12); ++ churnFoam=churn*(0.25+0.75*smoothstep(0.35,0.8,s0))*0.35; ++ } + float3 N = normalize(geometric + float3(-ripples.x, 0, -ripples.y)); + float3 V = normalize(p.camera.xyz - worldPosition); + float3 L = normalize(m.sunDirection.xyz); +@@ -270,7 +302,7 @@ + float nh = saturate(dot(N,H)); + float vh = saturate(dot(V,H)); + float fresnel = 0.0204 + 0.9796 * pow(1-nv, 5); +- float roughness = clamp(m.shallowColor.a + length(float2(ddx(N.x), ddy(N.z))) * 0.2, 0.065, 0.65); ++ float roughness = clamp(m.shallowColor.a + length(float2(ddx(N.x), ddy(N.z))) * 0.2 + churn * 0.25, 0.065, 0.65); + float a2 = pow(roughness, 4); + float d = a2 / max(PI * pow(nh*nh*(a2-1)+1, 2), 0.00001); + float k = pow(roughness + 1, 2) * 0.125; +@@ -349,7 +381,15 @@ + float breakup=foamBreakup(worldPosition.xz,p.camera.w,m.surface.yz); + float shore=wake.z*(1-smoothstep(30,100,abs(worldPosition.y-m.surface.x))); + float foam=saturate((wake.y*1.8+shore*1.3+crest+contact)*m.animation.w*breakup); ++ foam=saturate(foam+churnFoam); + color=lerp(color,float3(0.73,0.79,0.80)*(0.65+0.35*nl)*m.sunColor.a,foam); ++ // Sun glitter on churned liquid; sub-pixel cells are filtered out. ++ float2 glitterAt=worldPosition.xz*0.45; ++ float glitterFilter=1-smoothstep(0.35,1.0,length(fwidth(glitterAt))); ++ if (churn>0.001) { ++ float spark=smoothstep(0.93,1.0,foamHash(floor(glitterAt)+floor(p.camera.w*12)*float2(7.13,3.71))); ++ color+=m.sunColor.rgb*m.sunDirection.a*spark*pow(saturate(dot(reflect(-V,N),L)),4)*churn*glitterFilter*1.2; ++ } + if (p.fogColor.a > 0.5) color = lerp(color, p.fogColor.rgb, saturate(sourceColor.a)); + // The original mirrored scene has already passed through course fog. + // Compose it here to avoid applying that fog a second time and erasing diff --git a/patches/enhanced-water/native/WaterCaustics.png b/patches/enhanced-water/native/WaterCaustics.png new file mode 100644 index 0000000000000000000000000000000000000000..a8338448e28f1f5bc48027537be80d7c328c0e82 GIT binary patch literal 96610 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%0DWevHo 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 cases : register(t1); +StructuredBuffer draws : register(t1); RWStructuredBuffer results : register(u2); +StructuredBuffer inputs : register(t3); [numthreads(1,1,1)] void CSMain(uint index : SV_DispatchThreadID) { - uint count,stride; cases.GetDimensions(count,stride); + uint count,stride; inputs.GetDimensions(count,stride); if(index>=count)return; - ProjectionCase c=cases[index]; + ProjectionInput input=inputs[index]; + ProjectionCase c; + c.draw=draws[index];c.world=input.world;c.expectedPixel=input.expectedPixel;c.rawWorld=input.rawWorld; float4 pixel=waterPixelFromWorld(c.world.xyz,c.draw); float3 recovered=waterWorldFromPixel(c.expectedPixel.xy,c.expectedPixel.z,c.draw); float3 decoded=waterDecodeWorld(c.rawWorld,c.draw); diff --git a/runtime-recomp/cmake/EnhancedWater.cmake b/runtime-recomp/cmake/EnhancedWater.cmake index bdd2fca4..7e5fc5c2 100644 --- a/runtime-recomp/cmake/EnhancedWater.cmake +++ b/runtime-recomp/cmake/EnhancedWater.cmake @@ -28,7 +28,9 @@ set(water_projection "${DKRPORT_ROOT}/patches/enhanced-water/dkr-projection.patc set(water_recovery "${DKRPORT_ROOT}/patches/enhanced-water/dkr-recovery.patch") set(water_performance "${DKRPORT_ROOT}/patches/enhanced-water/dkr-performance.patch") set(water_reflection_search "${DKRPORT_ROOT}/patches/enhanced-water/dkr-reflection-search.patch") -set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${water_performance}" "${water_reflection_search}") +set(water_lowpass "${DKRPORT_ROOT}/patches/enhanced-water/dkr-lowpass.patch") +set(water_caustics "${DKRPORT_ROOT}/patches/enhanced-water/dkr-caustics.patch") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${water_performance}" "${water_reflection_search}" "${water_lowpass}" "${water_caustics}") set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${water_patch}" "${water_adapter}" "${water_budget}" "${water_safety}" "${water_views}" "${water_field_portability}" "${water_rays}" "${water_ray_caps}" "${water_projection}" "${water_recovery}") # Compose the capability adapter on the ACTIVE backend sources, including @@ -137,7 +139,7 @@ foreach(file IN ITEMS WaterProjection.hlsli WaterProjectionProbeCS.hlsl) configure_file("${DKRPORT_ROOT}/patches/enhanced-water/native/${file}" "${water_stage}/src/shaders/${file}" COPYONLY) endforeach() execute_process(COMMAND "${GIT_EXECUTABLE}" init --quiet "${water_stage}" COMMAND_ERROR_IS_FATAL ANY) -foreach(patch IN ITEMS "${water_patch}" "${water_adapter}" "${water_budget}" "${water_safety}" "${water_views}" "${water_field_portability}" "${water_rays}" "${water_ray_caps}" "${water_projection}" "${water_recovery}" "${water_performance}" "${water_reflection_search}") +foreach(patch IN ITEMS "${water_patch}" "${water_adapter}" "${water_budget}" "${water_safety}" "${water_views}" "${water_field_portability}" "${water_rays}" "${water_ray_caps}" "${water_projection}" "${water_recovery}" "${water_performance}" "${water_reflection_search}" "${water_lowpass}" "${water_caustics}") foreach(mode IN ITEMS check apply) set(flags --recount --ignore-space-change --whitespace=error) if(mode STREQUAL "check") @@ -244,5 +246,14 @@ dkr_water_shader(WaterSurfacePS WaterSurfacePS ps PSMain) dkr_water_shader(WaterSprayVS WaterSprayVS vs VSMain) dkr_water_shader(WaterSprayPS WaterSprayPS ps PSMain) dkr_water_shader(WaterSprayMSPS WaterSprayPS ps PSMain -D SPRAY_MSAA) +# The authored caustic pattern is embedded like the shader blobs, so no +# runtime file is packaged or looked up. +set(water_caustics_png "${DKRPORT_ROOT}/patches/enhanced-water/native/WaterCaustics.png") +set(water_caustics_out "${water_shader_out}/textures/WaterCaustics.png") +add_custom_command(OUTPUT "${water_caustics_out}.c" "${water_caustics_out}.h" + COMMAND file_to_c "${water_caustics_png}" WaterCausticsPNG "${water_caustics_out}.c" "${water_caustics_out}.h" + DEPENDS "${water_caustics_png}" file_to_c VERBATIM) +target_sources(rt64 PRIVATE "${water_caustics_out}.c") +list(APPEND water_shader_outputs "${water_caustics_out}.c" "${water_caustics_out}.h") add_custom_target(DKREnhancedWaterShaders DEPENDS ${water_shader_outputs}) add_dependencies(rt64 DKREnhancedWaterShaders) diff --git a/runtime-recomp/src/game/enhanced_water_rt64.hpp b/runtime-recomp/src/game/enhanced_water_rt64.hpp index 78d0a551..6b1302b5 100644 --- a/runtime-recomp/src/game/enhanced_water_rt64.hpp +++ b/runtime-recomp/src/game/enhanced_water_rt64.hpp @@ -50,10 +50,12 @@ inline const Registry& task_registry(RT64::State& state,std::uint32_t scene) { struct CraftFrame { std::array previous{},current{}; + // Low-pass clearance above the liquid: + aircraft, - hovercraft, 0 none. + std::array air{}; float previous_time=0; }; inline const CraftFrame& task_crafts(Memory m,const Registry& registry,std::uint32_t scene,float time) { - struct Cache {History history;CraftFrame frame;std::array objects{};}; + struct Cache {History history;CraftFrame frame;std::array objects{};unsigned trace_lines=0;float trace_next=0;}; static thread_local Cache cache; if(cache.history.valid && cache.history.scene==scene && cache.history.time==time)return cache.frame; const float old_time=float(cache.history.time); @@ -66,7 +68,9 @@ inline const CraftFrame& task_crafts(Memory m,const Registry& registry,std::uint const auto object=i=*height-60 && p.y<=*height+40 ? std::clamp(1.0F-(p.y-*height)/40.0F,0.0F,1.0F) : 0; if(finite(p))current={p.x,p.y,p.z,contact}; + // A low pass is visual too: aircraft downwash and hovercraft skirts + // disturb the liquid beneath them, above the hull-contact band. + if(height && p.y>=*height-60) { + const float clearance=(std::max)(p.y-*height,1.0F); + if(vehicle==2 && clearance<=WATER_LOWPASS_PLANE_CEILING)air=clearance; + if(vehicle==1 && clearance<=WATER_LOWPASS_HOVER_CEILING)air=-clearance; + } } const auto& old=cache.frame.previous[i]; const float dx=float(current.x-old.x),dz=float(current.z-old.z); @@ -81,6 +92,18 @@ inline const CraftFrame& task_crafts(Memory m,const Registry& registry,std::uint cache.frame.previous[i]=current; cache.objects[i]=object; } + // Bounded calibration trace for user-run low-pass playtests. + if(reset)cache.trace_next=0; + for(unsigned i=0;i<8 && cache.trace_lines<64 && time>=cache.trace_next;++i) { + if(cache.frame.air[i]==0)continue; + const auto& old=cache.frame.previous[i];const auto& now=cache.frame.current[i]; + const float dx=float(now.x-old.x),dz=float(now.z-old.z); + const float interval=time-cache.frame.previous_time; + std::fprintf(stderr,"[water][lowpass] scene=%u racer=%u %s clearance=%.1f speed=%.1f contact=%.2f\n", + scene,i,cache.frame.air[i]>0 ? "plane" : "hovercraft",std::abs(cache.frame.air[i]), + interval>0 ? std::sqrt(dx*dx+dz*dz)/interval : 0.0F,float(now.w)); + ++cache.trace_lines;cache.trace_next=time+2; + } return cache.frame; } @@ -146,6 +169,9 @@ inline void apply_material(RT64::State& state, const Registry& registry, material.craftCurrent[i].w=-1; } } + const auto& air=crafts.air; + material.craftAir[0]={air[0],air[1],air[2],air[3]}; + material.craftAir[1]={air[4],air[5],air[6],air[7]}; } if(dynamic) { // One bounded diagnostic per scene/outcome, not per wave tile/frame. diff --git a/runtime-recomp/tests/enhanced_water_ray_gpu_tests.cpp b/runtime-recomp/tests/enhanced_water_ray_gpu_tests.cpp index ac4eba34..78362fc2 100644 --- a/runtime-recomp/tests/enhanced_water_ray_gpu_tests.cpp +++ b/runtime-recomp/tests/enhanced_water_ray_gpu_tests.cpp @@ -175,9 +175,10 @@ void fieldProbe(RT64::RenderWorker& worker,plume::RenderShaderFormat format) { for(unsigned i=0;i<2;i++)if(timedCases[i])std::printf("Synthetic wake-only mean: resolution=%u baseline=%.3fms optimized=%.3fms (not gameplay FPS).\n",i ? 512 : 256,referenceMs[i]/timedCases[i],optimizedMs[i]/timedCases[i]); } struct ProjectionSet : plume::RenderDescriptorSetBase { - uint32_t input,output; + uint32_t draws,output,input; explicit ProjectionSet(plume::RenderDevice* device) { - builder.begin();input=builder.addStructuredBuffer(1);output=builder.addReadWriteStructuredBuffer(2);builder.end();create(device); + builder.begin();draws=builder.addStructuredBuffer(1);output=builder.addReadWriteStructuredBuffer(2); + input=builder.addStructuredBuffer(3);builder.end();create(device); if(!get())throw std::runtime_error("Projection descriptor allocation failed"); } }; @@ -185,7 +186,12 @@ struct ProjectionCase { interop::WaterDrawParams draw; interop::float4 world,expectedPixel,rawWorld; }; -static_assert(sizeof(ProjectionCase)==2048,"GPU projection probe ABI and D3D stride"); +// Draw parameters alone nearly fill D3D's 2048-byte structured stride, so the +// probe inputs are uploaded to a parallel buffer with the same index. +struct ProjectionInput { + interop::float4 world,expectedPixel,rawWorld; +}; +static_assert(sizeof(interop::WaterDrawParams)<=2048 && sizeof(ProjectionInput)==48,"GPU projection probe ABI and D3D stride"); void projectionProbe(RT64::RenderWorker& worker,plume::RenderShaderFormat format) { using namespace RT64; std::vector cases; @@ -234,19 +240,28 @@ void projectionProbe(RT64::RenderWorker& worker,plume::RenderShaderFormat format #endif shader=required(worker.device->createShader(WaterProjectionProbeCSBlobSPIRV,sizeof(WaterProjectionProbeCSBlobSPIRV),"CSMain",format)); auto pipeline=required(worker.device->createComputePipeline(RenderComputePipelineDesc(layout.get(),shader.get(),1,1,1))); - const uint64_t inputBytes=cases.size()*sizeof(ProjectionCase),outputBytes=cases.size()*16; + std::vector draws;std::vector inputs; + for(const auto& c:cases) {draws.push_back(c.draw);inputs.push_back({c.world,c.expectedPixel,c.rawWorld});} + const uint64_t drawBytes=draws.size()*sizeof(interop::WaterDrawParams),inputBytes=inputs.size()*sizeof(ProjectionInput); + const uint64_t outputBytes=cases.size()*16; + auto drawBuffer=required(worker.device->createBuffer(RenderBufferDesc::DefaultBuffer(drawBytes,RenderBufferFlag::STORAGE))); auto input=required(worker.device->createBuffer(RenderBufferDesc::DefaultBuffer(inputBytes,RenderBufferFlag::STORAGE))); - auto upload=required(worker.device->createBuffer(RenderBufferDesc::UploadBuffer(inputBytes))); + auto upload=required(worker.device->createBuffer(RenderBufferDesc::UploadBuffer(drawBytes+inputBytes))); auto output=required(worker.device->createBuffer(RenderBufferDesc::DefaultBuffer(outputBytes,RenderBufferFlag::STORAGE|RenderBufferFlag::UNORDERED_ACCESS))); auto readback=required(worker.device->createBuffer(RenderBufferDesc::ReadbackBuffer(outputBytes))); - const RenderRange none(0,0),written(0,inputBytes); + const RenderRange none(0,0),written(0,drawBytes+inputBytes); auto mapped=upload->map(0,&none);if(!mapped)throw std::runtime_error("No projection upload"); - std::memcpy(mapped,cases.data(),inputBytes);upload->unmap(0,&written); - set.setBuffer(set.input,input.get(),RenderBufferStructuredView(sizeof(ProjectionCase))); + std::memcpy(mapped,draws.data(),drawBytes); + std::memcpy(static_cast(mapped)+drawBytes,inputs.data(),inputBytes);upload->unmap(0,&written); + set.setBuffer(set.draws,drawBuffer.get(),RenderBufferStructuredView(sizeof(interop::WaterDrawParams))); + set.setBuffer(set.input,input.get(),RenderBufferStructuredView(sizeof(ProjectionInput))); set.setBuffer(set.output,output.get(),RenderBufferStructuredView(16)); worker.commandList->begin(); + worker.commandList->barriers(RenderBarrierStage::COPY,RenderBufferBarrier(drawBuffer.get(),RenderBufferAccess::WRITE)); worker.commandList->barriers(RenderBarrierStage::COPY,RenderBufferBarrier(input.get(),RenderBufferAccess::WRITE)); - worker.commandList->copyBufferRegion(input->at(0),upload->at(0),inputBytes); + worker.commandList->copyBufferRegion(drawBuffer->at(0),upload->at(0),drawBytes); + worker.commandList->copyBufferRegion(input->at(0),upload->at(drawBytes),inputBytes); + worker.commandList->barriers(RenderBarrierStage::COMPUTE,RenderBufferBarrier(drawBuffer.get(),RenderBufferAccess::READ)); worker.commandList->barriers(RenderBarrierStage::COMPUTE,RenderBufferBarrier(input.get(),RenderBufferAccess::READ)); worker.commandList->barriers(RenderBarrierStage::COMPUTE,RenderBufferBarrier(output.get(),RenderBufferAccess::WRITE)); worker.commandList->setComputePipelineLayout(layout.get());worker.commandList->setPipeline(pipeline.get()); diff --git a/scripts/compose-water-caustics.py b/scripts/compose-water-caustics.py new file mode 100644 index 00000000..4af4d889 --- /dev/null +++ b/scripts/compose-water-caustics.py @@ -0,0 +1,193 @@ +"""Author the caustic texture layer without editing dependencies. + +Read the fully composed stage; emit one final pipeline patch. Re-runs reverse +only this layer in memory. The caustic PNG is an authored native input that +EnhancedWater.cmake embeds with the dependency's own file_to_c tool. +""" +from pathlib import Path +import difflib +import sys +from water_patch_baseline import read_before + +root=Path(__file__).resolve().parents[1] +stage=Path(sys.argv[1]).resolve() +output=root/'patches/enhanced-water/dkr-caustics.patch' +layers=[output] if 'causticTexture' in (stage/'src/shaders/WaterPS.hlsl').read_text() else [] +changes=[] + +def replace(text,old,new): + if text.count(old)!=1: + raise SystemExit(f'Non-unique caustics context: {old[:120]}') + return text.replace(old,new,1) + +def change(path,fn): + before=read_before(stage,path,layers) + after=fn(before) + if before==after:raise SystemExit(f'No caustics changes: {path}') + changes.append(f'diff --git a/{path} b/{path}\n') + changes.extend(difflib.unified_diff(before.splitlines(True),after.splitlines(True), + fromfile=f'a/{path}',tofile=f'b/{path}',n=3)) + +def header(t): + t=replace(t,''' uint32_t draws, sceneColor, sceneDepth, currentField, previousField, originalColor; + explicit WaterDescriptorSet(RenderDevice *device = nullptr) {''', + ''' uint32_t draws, sceneColor, sceneDepth, currentField, previousField, originalColor, caustics; + explicit WaterDescriptorSet(RenderDevice *device = nullptr, const RenderSampler *causticSampler = nullptr) {''') + t=replace(t,''' originalColor = builder.addTexture(6); + builder.end();''',''' originalColor = builder.addTexture(6); + caustics = builder.addTexture(7); + // Immutable samplers go last: D3D12 drops them from the root table but + // still counts them in the set, so any later range would read empty. + builder.addImmutableSampler(8, causticSampler); + builder.end();''') + t=replace(t,''' BufferPair drawBuffer; + std::unique_ptr descriptorSet;''',''' BufferPair drawBuffer; + // Declared before every descriptor set: the immutable sampler and the + // texture must outlive the sets that sample them. + std::unique_ptr causticSampler; + std::unique_ptr causticTexture; + std::unique_ptr causticView; + std::unique_ptr causticUpload; + std::vector causticOffsets; + uint32_t causticSize=0; + bool causticPending=false; + std::unique_ptr descriptorSet;''') + return replace(t,' void recordField(RenderWorker *worker);\n', + ' void recordField(RenderWorker *worker);\n void recordCaustics(RenderWorker *worker);\n') +change('src/render/rt64_water_renderer.h',header) + +def renderer(t): + t=replace(t,'#include \n','''#include +#include "stb/stb_image.h" +#include "textures/WaterCaustics.png.h" +''') + t=replace(t,''' descriptorSet = std::make_unique(device); + RenderPipelineLayoutBuilder builder;''',''' // The caustic pattern is an authored grayscale PNG embedded at build time. + // Box-filtered mips are staged here and uploaded once by recordCaustics. + RenderSamplerDesc causticDesc; + causticDesc.maxAnisotropy=getDefaultSamplerAnisotropy(); + causticDesc.anisotropyEnabled=causticDesc.maxAnisotropy>1; + causticSampler=checkedWaterResource(device->createSampler(causticDesc)); + int causticWidth=0,causticHeight=0,causticChannels=0; + stbi_uc *causticPixels=stbi_load_from_memory(reinterpret_cast(WaterCausticsPNG),int(sizeof(WaterCausticsPNG)), + &causticWidth,&causticHeight,&causticChannels,1); + if (!causticPixels || causticWidth<1 || causticWidth!=causticHeight || (causticWidth&(causticWidth-1))) { + stbi_image_free(causticPixels); + throw std::runtime_error("water caustic texture is invalid; retaining Original"); + } + causticSize=uint32_t(causticWidth); + std::vector> causticMips{std::vector(causticPixels,causticPixels+size_t(causticSize)*causticSize)}; + stbi_image_free(causticPixels); + for (uint32_t size=causticSize;size>1;size/=2) { + const auto &source=causticMips.back(); + const uint32_t half=size/2; + std::vector next(size_t(half)*half); + for (uint32_t y=0;y>level,1u); + causticBytes+=((size+255)&~255u)*size; + } + causticUpload=checkedWaterResource(device->createBuffer(RenderBufferDesc::UploadBuffer(causticBytes))); + auto *causticStaging=static_cast(causticUpload->map()); + std::memset(causticStaging,0,causticBytes); + for (uint32_t level=0;level>level,1u), pitch=(size+255)&~255u; + for (uint32_t y=0;yunmap(); + causticTexture=checkedWaterResource(device->createTexture(RenderTextureDesc::Texture2D(causticSize,causticSize,uint32_t(causticMips.size()),RenderFormat::R8_UNORM))); + causticView=checkedWaterResource(causticTexture->createTextureView(RenderTextureViewDesc::Texture2D(RenderFormat::R8_UNORM))); + causticTexture->setName("Water authored caustic pattern"); + causticPending=true; + descriptorSet = std::make_unique(device,causticSampler.get()); + RenderPipelineLayoutBuilder builder;''') + t=replace(t,''' for (auto &scene : scenes) { scene.captured=false; scene.surfaceCleared=false; } +}''',''' for (auto &scene : scenes) { scene.captured=false; scene.surfaceCleared=false; } + // The workload that recorded the caustic upload has completed. + if (!causticPending) causticUpload.reset(); +}''') + t=replace(t,' scene.materialSet = std::make_unique(worker->device);\n', + ' scene.materialSet = std::make_unique(worker->device,causticSampler.get());\n') + t=replace(t,''' ms->setTexture(ms->sceneDepth,scene.depth.get(),RenderTextureLayout::SHADER_READ,scene.depthView.get()); +''',''' ms->setTexture(ms->sceneDepth,scene.depth.get(),RenderTextureLayout::SHADER_READ,scene.depthView.get()); + ms->setTexture(ms->caustics,causticTexture.get(),RenderTextureLayout::SHADER_READ,causticView.get()); +''') + return replace(t,'''uint32_t WaterRenderer::beginProfile(RenderWorker *worker, uint32_t kind) {''','''void WaterRenderer::recordCaustics(RenderWorker *worker) { + if (!causticPending) return; + worker->commandList->barriers(RenderBarrierStage::COPY,RenderTextureBarrier(causticTexture.get(),RenderTextureLayout::COPY_DEST)); + for (uint32_t level=0;level>level,1u); + worker->commandList->copyTextureRegion(RenderTextureCopyLocation::Subresource(causticTexture.get(),level), + RenderTextureCopyLocation::PlacedFootprint(causticUpload.get(),RenderFormat::R8_UNORM,size,size,1,(size+255)&~255u,causticOffsets[level])); + } + worker->commandList->barriers(RenderBarrierStage::GRAPHICS,RenderTextureBarrier(causticTexture.get(),RenderTextureLayout::SHADER_READ)); + causticPending=false; +} + +uint32_t WaterRenderer::beginProfile(RenderWorker *worker, uint32_t kind) {''') +change('src/render/rt64_water_renderer.cpp',renderer) + +def framebuffer(t): + return replace(t,''' if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordField(worker); +''',''' if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordField(worker); + if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordCaustics(worker); +''') +change('src/render/rt64_framebuffer_renderer.cpp',framebuffer) + +def pixels(t): + t=replace(t,'Texture2D originalColor : register(t6);\n','''Texture2D originalColor : register(t6); +Texture2D causticTexture : register(t7); +SamplerState causticSampler : register(s8); +''') + t=replace(t,''' // Derivatives must be evaluated for the whole pixel quad, before the + // depth-validity branch. A branch crossing a shoreline otherwise leaves + // caustic receiver normals/filter widths undefined on some backends. + float bottomDepth=sceneDepth.Load(int3(int2(boundPixel(refractedPixel,p)),0)); + float3 bottom=waterWorldFromPixel(refractedPixel,bottomDepth,p); + float3 bottomNormal=cross(ddx(bottom),ddy(bottom)); + bottomNormal*=rsqrt(max(dot(bottomNormal,bottomNormal),0.000001)); + if (bottomNormal.y<0) bottomNormal=-bottomNormal; + float2 causticUV=bottom.xz*0.035; + float causticFilter=1-smoothstep(0.25,1.5,length(fwidth(causticUV))); +''',''' // Caustics follow the reference water workflow: one authored pattern read + // twice on the water plane, panned in opposite directions, the second + // layer rescaled so the two never align. Mipmaps keep distant cells from + // breaking into stair-stepped lines. Sample before the depth-validity + // branch: a branch crossing a shoreline leaves derivatives undefined. + float2 causticUV=worldPosition.xz/130+ripples*0.6; + float caustic=causticTexture.Sample(causticSampler,causticUV+float2(-0.05,0.05)*p.camera.w) + +causticTexture.Sample(causticSampler,(causticUV+float2(0.05,-0.05)*p.camera.w)*0.66); + // Vertical water column under this pixel. Unlike the view-ray thickness it + // does not grow at grazing angles. Negative without a submerged surface. + float waterDepth=thickness>=0 ? max(worldPosition.y-waterWorldFromPixel(position.xy, + sceneDepth.Load(int3(int2(position.xy),0)),p).y,0) : -1; +''') + return replace(t,''' // Stable world-space light focusing, confined to an actual shallow + // submerged receiver. Dark source lighting and steep walls reject it. + float phaseA=causticUV.x+sin(causticUV.y*1.3+p.camera.w*0.65); + float phaseB=causticUV.y-sin(causticUV.x*1.1-p.camera.w*0.5); + float focusing=pow(saturate(1-abs(sin(phaseA)+sin(phaseB)*0.55)),12); + float receiver=smoothstep(0.2,0.65,bottomNormal.y)*saturate(dot(bottomNormal,L)); + receiver*=smoothstep(0.015,0.08,dot(bottomColor,float3(0.2126,0.7152,0.0722))); + receiver*=bottom.y0) return 1-smoothstep(WATER_LOWPASS_PLANE_FULL,WATER_LOWPASS_PLANE_CEILING,air); + if (air<0) return WATER_LOWPASS_HOVER_SCALE*(1-smoothstep(WATER_LOWPASS_HOVER_FULL,WATER_LOWPASS_HOVER_CEILING,-air)); + return 0; +} +#endif +''' +change('src/shared/rt64_water_params.h',params) + +def header(t): + return replace(t,' std::array sprayHeading{};\n', + ' std::array sprayHeading{};\n' + ' std::array lowPassDistance{};\n') +change('src/render/rt64_water_renderer.h',header) + +def renderer(t): + t=replace(t,'void WaterRenderer::stepSpray(const interop::WaterMaterial &m,float fromTime,float toTime,bool reset) {\n', + '''// Matches waterLowPassStrength in rt64_water_params.h. +static float lowPassStrength(float air) { + const auto fade=[](float full,float ceiling,float clearance) { + const float t=std::clamp((clearance-full)/(ceiling-full),0.0f,1.0f); + return 1-t*t*(3-2*t); + }; + if (air>0) return fade(WATER_LOWPASS_PLANE_FULL,WATER_LOWPASS_PLANE_CEILING,air); + if (air<0) return WATER_LOWPASS_HOVER_SCALE*fade(WATER_LOWPASS_HOVER_FULL,WATER_LOWPASS_HOVER_CEILING,-air); + return 0; +} + +void WaterRenderer::stepSpray(const interop::WaterMaterial &m,float fromTime,float toTime,bool reset) { +''') + t=replace(t,' particles.clear(); sprayDistance.fill(0); sprayHeading.fill({0,0,0,0});\n', + ' particles.clear(); sprayDistance.fill(0); sprayHeading.fill({0,0,0,0}); lowPassDistance.fill(0);\n') + a=t.index('void WaterRenderer::drawSpray') + end=t.rindex(' }\n}\n',0,a) + t=t[:end]+''' } + // Low passes lift spray from the liquid beneath the craft, not from its + // hull: outward curtains (kind 2) and slowly settling mist (kind 1). + for (uint32_t i=0;i0;});''', + ''' // A low pass over the liquid keeps the field alive without hull contact. + bool lowPass=false; + for (uint32_t i=0;i=0 && m.craftAir[i/4][i%4]!=0; + const bool active=horizontal!=draws.end() && (lowPass || + std::any_of(std::begin(m.craftCurrent),std::end(m.craftCurrent),[](const auto& c){return c.w>0;}));''') + return t +change('src/render/rt64_water_renderer.cpp',renderer) + +def field(t): + t=replace(t,'groupshared float4 craftResponse[WATER_CRAFT_COUNT];\n', + 'groupshared float4 craftResponse[WATER_CRAFT_COUNT];\n' + 'groupshared float4 craftWash[WATER_CRAFT_COUNT];\n') + t=replace(t,''' craftResponse[lane]=float4(saturate((old.y-current.y)/interval/180),landed ? 1 : 0,current.w,0); +''',''' craftResponse[lane]=float4(saturate((old.y-current.y)/interval/180),landed ? 1 : 0,current.w,0); + // Low pass: a footprint trailing beneath the craft, wider when higher. + float air=m.craftAir[lane/4][lane%4]; + float clearance=abs(air); + float wash=current.w>=0 ? waterLowPassStrength(air)*saturate(distance/interval/WATER_LOWPASS_FULL_SPEED) : 0; + craftWash[lane]=float4(to-heading*(10+clearance*0.15),20+clearance*0.3,wash*min(length(to-from)/cell,2)); +''') + t=replace(t,''' foam += ring*0.65; + velocity += ring*0.7; + } +''',''' foam += ring*0.65; + velocity += ring*0.7; + } + float4 wash=craftWash[i]; + if (wash.w>0) { + float2 offset=world-wash.xy; + float r=wash.z, behind=max(-dot(offset,heading),0); + float lateral=abs(dot(offset,float2(-heading.y,heading.x))); + // The moving pressure dent radiates its own waves; curtains spread + // wider than the hull wake; a churned trail follows the path. + float dent=exp(-dot(offset,offset)/(r*r)); + float curtain=exp(-pow((lateral-behind*0.55-r*0.35)/(6+r*0.25),2))*smoothstep(0,r*0.5,behind)*(1-smoothstep(r*2.5,r*4.5,behind)); + float churn=exp(-pow(d/(r*0.5),2)); + foam += (churn*0.035+curtain*0.03)*wash.w; + velocity += (curtain*0.05-dent*0.12)*wash.w; + } +''') + return t +change('src/shaders/WaterFieldCS.hlsl',field) + +def spray_vs(t): + t=replace(t,' out float3 world : TEXCOORD1, out float2 fade : TEXCOORD2) {', + ' out float3 world : TEXCOORD1, out float4 fade : TEXCOORD2) {') + t=replace(t,''' float drag=1.5, response=(1-exp(-drag*age))/drag; + world=particle.origin.xyz+particle.velocity.xyz*response; + world.y-=140*(age-response)/drag; + float4 clip=mul(p.viewProjection,float4(world,1)); + float4 top=mul(p.viewProjection,float4(world+float3(0,particle.appearance.x,0),1));''',''' // Kind 0 is hull spray, 1 low-pass mist, 2 low-pass curtain droplets. + float kind=particle.appearance.w; + bool mist=kind>0.5 && kind<1.5, streak=kind>1.5; + float life=saturate(age/particle.velocity.w); + float drag=mist ? 2.6 : 1.5, gravity=mist ? 12 : 140, response=(1-exp(-drag*age))/drag; + world=particle.origin.xyz+particle.velocity.xyz*response; + world.y-=gravity*(age-response)/drag; + float size=particle.appearance.x*(mist ? 1+life*1.2 : 1); + float4 clip=mul(p.viewProjection,float4(world,1)); + float4 top=mul(p.viewProjection,float4(world+float3(0,size,0),1));''') + t=replace(t,''' position=float4(xy*clip.w,screen.z*clip.w,clip.w); + float radius=clamp(projectedRadius,0.0005,0.035); + position.xy+=uv*radius*float2(p.screen.y/p.screen.x,1)*clip.w; + float life=saturate(age/particle.velocity.w); + float distance=length(world-p.camera.xyz); + float nearFade=smoothstep(particle.appearance.x*4,particle.appearance.x*12,distance); + float sizeFade=1-smoothstep(0.012,0.035,projectedRadius); + fade=float2(particle.appearance.y*smoothstep(0,0.07,age)*(1-smoothstep(0.55,1,life))*nearFade*sizeFade,distance);''',''' position=float4(xy*clip.w,screen.z*clip.w,clip.w); + // Streaks keep a minimum width so fine droplets stay visible. + float radius=clamp(projectedRadius,streak ? 0.0022 : 0.0005,mist ? 0.24 : streak ? 0.12 : 0.035); + float2 aspect=float2(p.screen.y/p.screen.x,1); + float2 offset=uv*radius*aspect; + float energy=1; + if (streak) { + // Stretch curtain droplets along their recent screen motion. + float decay=exp(-drag*age); + float3 velocity=particle.velocity.xyz*decay-float3(0,gravity*(1-decay)/drag,0); + float4 tail=mul(p.viewProjection,float4(world-velocity*0.09,1)); + if (all(isfinite(tail)) && tail.w>0.01) { + float3 tailScreen=tail.xyz/tail.w*float3(1,-1,1)*p.viewportScale.xyz+p.viewportTranslate.xyz; + float2 tailXY=(tailScreen.xy/p.screen.xy*float2(2,-2)+float2(-1,1))*p.screenTransform.xy+p.screenTransform.zw; + float2 trail=(xy-tailXY)/aspect; + float span=length(trail); + if (all(isfinite(trail)) && span>radius*0.5) { + float2 along=trail/span, across=float2(-along.y,along.x); + span=min(span,0.2); + offset=(across*uv.x*radius+along*(uv.y*(span*0.5+radius)-span*0.5))*aspect; + energy=pow(saturate(2*radius/(span+2*radius)),0.3); + } + } + } + position.xy+=offset*clip.w; + float distance=length(world-p.camera.xyz); + float nearFade=mist ? smoothstep(size*1.5,size*4,distance) : smoothstep(particle.appearance.x*4,particle.appearance.x*12,distance); + float sizeFade=mist ? 1-smoothstep(0.14,0.24,projectedRadius) : streak ? 1-smoothstep(0.07,0.12,projectedRadius) : 1-smoothstep(0.012,0.035,projectedRadius); + fade=float4(particle.appearance.y*smoothstep(0,0.07,age)*(1-smoothstep(0.55,1,life))*nearFade*sizeFade*energy,distance,kind,particle.appearance.z);''') + return t +change('src/shaders/WaterSprayVS.hlsl',spray_vs) + +def spray_ps(t): + t=replace(t,' float3 world : TEXCOORD1, float2 fade : TEXCOORD2) : SV_TARGET0 {', + ' float3 world : TEXCOORD1, float4 fade : TEXCOORD2) : SV_TARGET0 {') + t=replace(t,''' float alpha=(1-smoothstep(0.10,1,radius))*fade.x;''',''' bool mist=fade.z>0.5 && fade.z<1.5; + // Streaks are brightest at the head (uv.y=+1) and fade toward the tail. + float streak=(1-smoothstep(0.15,1,abs(uv.x)))*smoothstep(-1,0.3,uv.y)*(1-smoothstep(0.75,1,uv.y)); + float alpha=(mist ? exp(-radius*2.2)*(1-smoothstep(0.6,1,radius)) : fade.z>1.5 ? streak : 1-smoothstep(0.10,1,radius))*fade.x; + float soft=mist ? 40 : 12;''') + t=replace(t,' if (water>0) alpha*=saturate((water-fade.y)/12);', + ' if (water>0) alpha*=saturate((water-fade.y)/soft);') + t=replace(t,' alpha*=saturate((length(background-p.camera.xyz)-fade.y)/12);', + ' alpha*=saturate((length(background-p.camera.xyz)-fade.y)/soft);') + t=replace(t,''' float3 light=(p.material.skyColor.rgb*0.3+p.material.sunColor.rgb*0.7)*p.material.sunColor.a; +''',''' float3 light=(p.material.skyColor.rgb*0.3+p.material.sunColor.rgb*0.7)*p.material.sunColor.a; + if (fade.z>0.5) { + // Low-pass spray is lit white water, glowing faintly against the sun. + light=max(light,float3(0.62,0.66,0.68)*p.material.sunColor.a*2); + float scatter=pow(saturate(dot(normalize(world-p.camera.xyz),normalize(p.material.sunDirection.xyz))),6); + light+=p.material.sunColor.rgb*scatter*(mist ? 0.55 : 0.35); + // A few droplets catch the sun as glints. + if (fade.z>1.5 && fade.w>0.85) light+=p.material.sunColor.rgb*(0.25+scatter); + } +''') + return t +change('src/shaders/WaterSprayPS.hlsl',spray_ps) + +def surface(t): + t=replace(t,''' ripples += clamp(wakeGradient*8,-0.10,0.10); +''',''' ripples += clamp(wakeGradient*8,-0.10,0.10); + // Low pass: downwash and hovercraft skirts roughen and whiten a teardrop + // of liquid beneath and behind each craft. Analytic, so it never lags. + float churn=0,churnFoam=0; + float2 churnHeading=0; + { + float interval=max(m.animation.y-m.animation.x,0.00001); + float f=saturate((p.camera.w-m.animation.x)/interval); + [loop] for (uint i=0;i0 ? radius*0.7 : radius*3.5; + float footprint=exp(-pow(lateral/radius,2)-pow(ahead/extent,2))*wash; + if (footprint>churn) { churn=footprint; churnHeading=heading; } + } + } + if (churn>0.001) { + // World-anchored streaks along the heading: the craft sweeps over them. + float2 across=float2(-churnHeading.y,churnHeading.x); + float2 streakAt=float2(dot(worldPosition.xz,across)*0.16,dot(worldPosition.xz,churnHeading)*0.012); + float s0=foamNoise(streakAt); + float2 slope=across*(foamNoise(streakAt+float2(0.35,0))-s0)*0.5+churnHeading*(foamNoise(streakAt+float2(0,0.03))-s0)*0.2; + ripples += clamp(slope*churn*0.6,-0.12,0.12); + churnFoam=churn*(0.25+0.75*smoothstep(0.35,0.8,s0))*0.35; + } +''') + t=replace(t,''' float roughness = clamp(m.shallowColor.a + length(float2(ddx(N.x), ddy(N.z))) * 0.2, 0.065, 0.65);''', + ''' float roughness = clamp(m.shallowColor.a + length(float2(ddx(N.x), ddy(N.z))) * 0.2 + churn * 0.25, 0.065, 0.65);''') + t=replace(t,''' float foam=saturate((wake.y*1.8+shore*1.3+crest+contact)*m.animation.w*breakup); + color=lerp(color,float3(0.73,0.79,0.80)*(0.65+0.35*nl)*m.sunColor.a,foam); +''',''' float foam=saturate((wake.y*1.8+shore*1.3+crest+contact)*m.animation.w*breakup); + foam=saturate(foam+churnFoam); + color=lerp(color,float3(0.73,0.79,0.80)*(0.65+0.35*nl)*m.sunColor.a,foam); + // Sun glitter on churned liquid; sub-pixel cells are filtered out. + float2 glitterAt=worldPosition.xz*0.45; + float glitterFilter=1-smoothstep(0.35,1.0,length(fwidth(glitterAt))); + if (churn>0.001) { + float spark=smoothstep(0.93,1.0,foamHash(floor(glitterAt)+floor(p.camera.w*12)*float2(7.13,3.71))); + color+=m.sunColor.rgb*m.sunDirection.a*spark*pow(saturate(dot(reflect(-V,N),L)),4)*churn*glitterFilter*1.2; + } +''') + return t +change('src/shaders/WaterPS.hlsl',surface) + +output.write_text(''.join(changes),newline='\n') +print('Generated low-pass spray patch.') From 0598e9a8cf894d4777d4ea36a63f2ac1a86c941e Mon Sep 17 00:00:00 2001 From: leite-matheus <78598648+leite-matheus@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:40:56 -0300 Subject: [PATCH 05/11] Re-author the low-pass and caustics layers on main's lava/waterfall chain WaterDrawParams is a full 2048 bytes on main, so the per-craft clearance no longer gets its own field: it lives in the last two shoreline vectors (WATER_CRAFT_AIR), which water draws never fill and lava never reaches (it uses segments 0..15). Clearance is written only for water draws. Patches regenerated by the compose scripts; shader math and constants unchanged. --- patches/enhanced-water/dkr-caustics.patch | 64 +++++++++---------- patches/enhanced-water/dkr-lowpass.patch | 62 ++++++++++-------- .../src/game/enhanced_water_rt64.hpp | 9 ++- scripts/compose-water-caustics.py | 8 +-- scripts/compose-water-lowpass.py | 43 ++++++++----- 5 files changed, 107 insertions(+), 79 deletions(-) diff --git a/patches/enhanced-water/dkr-caustics.patch b/patches/enhanced-water/dkr-caustics.patch index 4af1d041..6e97cd26 100644 --- a/patches/enhanced-water/dkr-caustics.patch +++ b/patches/enhanced-water/dkr-caustics.patch @@ -1,7 +1,7 @@ diff --git a/src/render/rt64_water_renderer.h b/src/render/rt64_water_renderer.h --- a/src/render/rt64_water_renderer.h +++ b/src/render/rt64_water_renderer.h -@@ -88,8 +88,8 @@ +@@ -95,8 +95,8 @@ } }; struct WaterDescriptorSet : RenderDescriptorSetBase { @@ -12,7 +12,7 @@ diff --git a/src/render/rt64_water_renderer.h b/src/render/rt64_water_renderer.h builder.begin(); draws = builder.addStructuredBuffer(1); sceneColor = builder.addTexture(2); -@@ -97,6 +97,10 @@ +@@ -104,6 +104,10 @@ currentField = builder.addTexture(4); previousField = builder.addTexture(5); originalColor = builder.addTexture(6); @@ -23,7 +23,7 @@ diff --git a/src/render/rt64_water_renderer.h b/src/render/rt64_water_renderer.h builder.end(); if (device) create(device); } -@@ -106,6 +110,15 @@ +@@ -113,6 +117,15 @@ struct WaterRenderer { std::vector draws; BufferPair drawBuffer; @@ -38,8 +38,8 @@ diff --git a/src/render/rt64_water_renderer.h b/src/render/rt64_water_renderer.h + bool causticPending=false; std::unique_ptr descriptorSet; std::unique_ptr layout; - std::unique_ptr vertexShader; -@@ -217,6 +230,7 @@ + struct WaterfallSet : RenderDescriptorSetBase { +@@ -259,6 +272,7 @@ uint64_t allocationBytes() const; void prepareField(RenderWorker *worker); void recordField(RenderWorker *worker); @@ -50,19 +50,19 @@ diff --git a/src/render/rt64_water_renderer.h b/src/render/rt64_water_renderer.h diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer.cpp --- a/src/render/rt64_water_renderer.cpp +++ b/src/render/rt64_water_renderer.cpp -@@ -6,6 +6,8 @@ +@@ -11,6 +11,8 @@ #include #include #include +#include "stb/stb_image.h" +#include "textures/WaterCaustics.png.h" #include "shaders/WaterVS.hlsl.spirv.h" - #include "shaders/WaterPS.hlsl.spirv.h" - #include "shaders/WaterHardwarePS.hlsl.spirv.h" -@@ -90,7 +92,53 @@ - WaterRenderer::WaterRenderer(RenderDevice *device, RenderShaderFormat format, bool hdr) : usesHDR(hdr) { - if (std::getenv("WR64_TEST_WATER_SHADER_FAILURE")) throw std::runtime_error("injected water shader failure"); - if (std::getenv("DKRR_WATER_PROFILE") && device->getCapabilities().queryPools) profilePool = device->createQueryPool(128); + #include "shaders/WaterfallVS.hlsl.spirv.h" + #include "shaders/WaterfallPS.hlsl.spirv.h" +@@ -124,7 +126,53 @@ + waterfallVS=checkedWaterResource(device->createShader(CREATE_SHADER_INPUTS(WaterfallVSBlobDXIL,WaterfallVSBlobSPIRV,WaterfallVSBlobMSL,"VSMain",format))); + waterfallPS=checkedWaterResource(device->createShader(CREATE_SHADER_INPUTS(WaterfallPSBlobDXIL,WaterfallPSBlobSPIRV,WaterfallPSBlobMSL,"PSMain",format))); + waterfallMSPS=checkedWaterResource(device->createShader(CREATE_SHADER_INPUTS(WaterfallMSPSBlobDXIL,WaterfallMSPSBlobSPIRV,WaterfallMSPSBlobMSL,"PSMain",format))); - descriptorSet = std::make_unique(device); + // The caustic pattern is an authored grayscale PNG embedded at build time. + // Box-filtered mips are staged here and uploaded once by recordCaustics. @@ -114,33 +114,33 @@ diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer RenderPipelineLayoutBuilder builder; builder.begin(false, true); // Water vertices use the input assembler on D3D12. builder.addPushConstant(0, 0, sizeof(interop::WaterPushConstants), RenderShaderStageFlag::VERTEX | RenderShaderStageFlag::PIXEL); -@@ -195,6 +243,8 @@ +@@ -233,6 +281,8 @@ wireIndices.clear(); wireStarts.clear(); profileKinds.clear(); - for (auto &scene : scenes) { scene.captured=false; scene.surfaceCleared=false; } + for (auto &scene : scenes) { scene.captured=false; scene.opticalCaptured=false; scene.surfaceCleared=false; scene.lavaPrepared=false; } + // The workload that recorded the caustic upload has completed. + if (!causticPending) causticUpload.reset(); } - uint32_t WaterRenderer::prepareScene(RenderWorker *worker, RenderTarget *color, RenderTarget *depth, uint64_t key, bool classic, bool selfDepth) { -@@ -265,7 +315,7 @@ - scene.color->setName("Water immutable scene color"); - scene.depth->setName("Water immutable scene depth"); - scene.captureSet = std::make_unique(worker->device); -- scene.materialSet = std::make_unique(worker->device); -+ scene.materialSet = std::make_unique(worker->device,causticSampler.get()); - } - if (classic && (!scene.originalColor || scene.originalSamples!=color->multisampling.sampleCount)) { - scene.originalSet.reset(); scene.originalColorView.reset(); scene.originalColor.reset(); -@@ -330,6 +380,7 @@ - classic ? scene.originalColorView.get() : scene.colorView.get()); + uint32_t WaterRenderer::prepareScene(RenderWorker *worker, RenderTarget *color, RenderTarget *depth, uint64_t key, bool classic, bool selfDepth,water_work::Region originalRegion) { +@@ -313,7 +363,7 @@ + } + } + scene.captureSet=std::make_unique(worker->device); +- scene.materialSet=std::make_unique(worker->device); ++ scene.materialSet=std::make_unique(worker->device,causticSampler.get()); + if(!classic) { + scene.surfaceView=checkedWaterResource(scene.surface->createTextureView(RenderTextureViewDesc::Texture2D(RenderFormat::R32_FLOAT))); + scene.surfaceWidth=width;scene.surfaceHeight=height;scene.spraySet=std::make_unique(worker->device); +@@ -348,6 +398,7 @@ + ms->setTexture(ms->originalColor,scene.color.get(),RenderTextureLayout::SHADER_READ,scene.colorView.get()); ms->setTexture(ms->sceneColor,scene.color.get(),RenderTextureLayout::SHADER_READ,scene.colorView.get()); ms->setTexture(ms->sceneDepth,scene.depth.get(),RenderTextureLayout::SHADER_READ,scene.depthView.get()); + ms->setTexture(ms->caustics,causticTexture.get(),RenderTextureLayout::SHADER_READ,causticView.get()); - sceneKeys.emplace(sceneKey,index); + if(!existing)sceneKeys.emplace(sceneKey,index); return index; } catch(...) { -@@ -713,6 +764,18 @@ +@@ -756,6 +807,18 @@ }); endProfile(worker,token); @@ -162,7 +162,7 @@ diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer diff --git a/src/render/rt64_framebuffer_renderer.cpp b/src/render/rt64_framebuffer_renderer.cpp --- a/src/render/rt64_framebuffer_renderer.cpp +++ b/src/render/rt64_framebuffer_renderer.cpp -@@ -1332,6 +1332,7 @@ +@@ -1360,6 +1360,7 @@ if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->rays.record(worker); if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordNormals(worker, *outputBuffers); if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordField(worker); @@ -173,7 +173,7 @@ diff --git a/src/render/rt64_framebuffer_renderer.cpp b/src/render/rt64_framebuf diff --git a/src/shaders/WaterPS.hlsl b/src/shaders/WaterPS.hlsl --- a/src/shaders/WaterPS.hlsl +++ b/src/shaders/WaterPS.hlsl -@@ -6,6 +6,8 @@ +@@ -7,6 +7,8 @@ Texture2D currentField : register(t4); Texture2D previousField : register(t5); Texture2D originalColor : register(t6); @@ -182,7 +182,7 @@ diff --git a/src/shaders/WaterPS.hlsl b/src/shaders/WaterPS.hlsl static const float PI = 3.14159265359; -@@ -316,16 +318,18 @@ +@@ -354,16 +356,18 @@ float refractedThickness = opticalDepth(refractedPixel,position.z,worldPosition,p); // Reject foreground silhouettes at the proposed sample before distorting. if (refractedThickness < 0) { refractedPixel = position.xy; refractedThickness = thickness; } @@ -211,7 +211,7 @@ diff --git a/src/shaders/WaterPS.hlsl b/src/shaders/WaterPS.hlsl if (thickness >= 0 && refractedThickness >= 0) { float3 transmission = exp(-m.deepColor.a * float3(1.8,0.55,0.30) * refractedThickness); // Course geometry often ends a short distance below the shoreline. -@@ -339,15 +343,12 @@ +@@ -377,15 +381,12 @@ // Uncovered taps transition to the water body, not an unrelated // background color. Keep fractional coverage for a soft boundary. transmission*=receiverColor.a; diff --git a/patches/enhanced-water/dkr-lowpass.patch b/patches/enhanced-water/dkr-lowpass.patch index 2f17a63e..8046b6b5 100644 --- a/patches/enhanced-water/dkr-lowpass.patch +++ b/patches/enhanced-water/dkr-lowpass.patch @@ -16,25 +16,37 @@ diff --git a/src/shared/rt64_water_params.h b/src/shared/rt64_water_params.h #ifdef HLSL_CPU namespace interop { -@@ -26,6 +34,9 @@ - // matrix belongs to this draw's viewport, not a later live guest camera. +@@ -20,6 +28,7 @@ + float4 craftPrevious[WATER_CRAFT_COUNT]; // world XYZ, wet contact fraction (-1 means inactive) + float4 craftCurrent[WATER_CRAFT_COUNT]; + float4 shoreCounts[2]; // bounded static shoreline segments in each craft's world tile ++ // Water draws carry low-pass clearance in the last two shoreline vectors (WATER_CRAFT_AIR). + float4 shoreSegments[WATER_CRAFT_COUNT * WATER_SHORE_SEGMENTS_PER_TILE]; // immutable mean-water intersections, XZ endpoints + float4 performance; // SSR steps, wake resolution, hardware distance, spray tier + float4 appearance; // reflection, refraction, wake and waterfall strengths +@@ -29,6 +38,17 @@ float4x4 cameraViewProjection; float4 cameraPosition; // XYZ, W=1 marks the reviewed DKR adapter -+ // Clearance above the liquid, four crafts per vector: positive values -+ // are aircraft, negative values hovercraft, zero means no low pass. -+ float4 craftAir[WATER_CRAFT_COUNT/4]; }; ++ ++// Low-pass clearance above the liquid, four crafts per vector: positive ++// values are aircraft, negative values hovercraft, zero means no low pass. ++// WaterDrawParams is exactly at the 2048-byte structured-buffer stride, so ++// the clearance shares the last two shoreline vectors instead of growing it. ++// Water draws publish no shoreline segments (every shoreCounts entry stays ++// zero, so the field shaders never read them) and lava draws reuse only ++// shoreSegments[0..15] for emitters and write no clearance. Low passes are ++// gated on active crafts (W>=0), which lava and vertical water never have. ++#define WATER_CRAFT_AIR_SEGMENT (WATER_CRAFT_COUNT * WATER_SHORE_SEGMENTS_PER_TILE - WATER_CRAFT_COUNT / 4) ++#define WATER_CRAFT_AIR(m, i) ((m).shoreSegments[WATER_CRAFT_AIR_SEGMENT + (i) / 4][(i) % 4]) struct WaterDrawParams { -@@ -60,10 +71,19 @@ - float4 simulation; // step seconds, previous time, current time, tile extent - }; - #ifdef HLSL_CPU --static_assert(sizeof(WaterMaterial) == 1552, "DKR water task ABI"); --static_assert(sizeof(WaterDrawParams) == 2000, "DKR water shader ABI"); -+static_assert(sizeof(WaterMaterial) == 1584, "DKR water task ABI"); -+static_assert(sizeof(WaterDrawParams) == 2032, "DKR water shader ABI"); + WaterMaterial material; +@@ -66,7 +86,17 @@ + static_assert(sizeof(WaterMaterial) == 1584, "DKR water task ABI"); + static_assert(sizeof(WaterDrawParams) == 2048, "DKR water shader ABI"); static_assert(sizeof(WaterDrawParams) <= 2048, "D3D structured-buffer stride limit"); ++static_assert(WATER_CRAFT_AIR_SEGMENT >= 16, "Low-pass clearance must not overlap the lava emitters"); static_assert(sizeof(WaterFieldPushConstants) == 32, "Portable water field shader ABI"); static_assert(sizeof(WaterFieldPushConstants) <= 128, "Vulkan guaranteed push-constant budget"); } @@ -51,7 +63,7 @@ diff --git a/src/shared/rt64_water_params.h b/src/shared/rt64_water_params.h diff --git a/src/render/rt64_water_renderer.h b/src/render/rt64_water_renderer.h --- a/src/render/rt64_water_renderer.h +++ b/src/render/rt64_water_renderer.h -@@ -122,6 +122,7 @@ +@@ -155,6 +155,7 @@ BufferPair particleBuffer; std::array sprayDistance{}; std::array sprayHeading{}; @@ -62,7 +74,7 @@ diff --git a/src/render/rt64_water_renderer.h b/src/render/rt64_water_renderer.h diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer.cpp --- a/src/render/rt64_water_renderer.cpp +++ b/src/render/rt64_water_renderer.cpp -@@ -457,9 +457,20 @@ +@@ -498,9 +498,20 @@ worker->commandList->setFramebuffer(scene.waterColorFramebuffer.get()); } @@ -84,7 +96,7 @@ diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer spraySeed=0x57A064u^uint32_t(m.identity.w); return; } -@@ -495,6 +506,45 @@ +@@ -536,6 +547,45 @@ particles.push_back(p); } } @@ -92,7 +104,7 @@ diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer + // hull: outward curtains (kind 2) and slowly settling mist (kind 1). + for (uint32_t i=0;i0;}); + // A low pass over the liquid keeps the field alive without hull contact. + bool lowPass=false; -+ for (uint32_t i=0;i=0 && m.craftAir[i/4][i%4]!=0; ++ for (uint32_t i=0;i=0 && WATER_CRAFT_AIR(m,i)!=0; + const bool active=horizontal!=draws.end() && (lowPass || + std::any_of(std::begin(m.craftCurrent),std::end(m.craftCurrent),[](const auto& c){return c.w>0;})); - const uint32_t resolution=water_work::fieldResolution(active,bool(fieldTextures[0]),fieldResolution,uint32_t(m.identity.x)); + const uint32_t resolution=active && m.performance.y>=128 ? uint32_t(m.performance.y) : water_work::fieldResolution(active,bool(fieldTextures[0]),fieldResolution,uint32_t(m.identity.x)); constexpr float extent=4096, dt=1.0f/60; if (fieldResolution!=resolution) { diff --git a/src/shaders/WaterFieldCS.hlsl b/src/shaders/WaterFieldCS.hlsl @@ -159,7 +171,7 @@ diff --git a/src/shaders/WaterFieldCS.hlsl b/src/shaders/WaterFieldCS.hlsl bool landed=!params.reset && old.w==0 && current.w>0 && params.simulation.y=m.animation.y; craftResponse[lane]=float4(saturate((old.y-current.y)/interval/180),landed ? 1 : 0,current.w,0); + // Low pass: a footprint trailing beneath the craft, wider when higher. -+ float air=m.craftAir[lane/4][lane%4]; ++ float air=WATER_CRAFT_AIR(m,lane); + float clearance=abs(air); + float wash=current.w>=0 ? waterLowPassStrength(air)*saturate(distance/interval/WATER_LOWPASS_FULL_SPEED) : 0; + craftWash[lane]=float4(to-heading*(10+clearance*0.15),20+clearance*0.3,wash*min(length(to-from)/cell,2)); @@ -304,7 +316,7 @@ diff --git a/src/shaders/WaterSprayPS.hlsl b/src/shaders/WaterSprayPS.hlsl diff --git a/src/shaders/WaterPS.hlsl b/src/shaders/WaterPS.hlsl --- a/src/shaders/WaterPS.hlsl +++ b/src/shaders/WaterPS.hlsl -@@ -261,6 +261,38 @@ +@@ -299,6 +299,38 @@ float determinant=dx.x*dy.y-dx.y*dy.x; float2 wakeGradient=float2(ddx(wake.x)*dy.y-ddy(wake.x)*dx.y,ddy(wake.x)*dx.x-ddx(wake.x)*dy.x)/max(abs(determinant),0.001)*sign(determinant); ripples += clamp(wakeGradient*8,-0.10,0.10); @@ -316,7 +328,7 @@ diff --git a/src/shaders/WaterPS.hlsl b/src/shaders/WaterPS.hlsl + float interval=max(m.animation.y-m.animation.x,0.00001); + float f=saturate((p.camera.w-m.animation.x)/interval); + [loop] for (uint i=0;i(device,causticSampler.get()); RenderPipelineLayoutBuilder builder;''') - t=replace(t,''' for (auto &scene : scenes) { scene.captured=false; scene.surfaceCleared=false; } -}''',''' for (auto &scene : scenes) { scene.captured=false; scene.surfaceCleared=false; } + t=replace(t,''' for (auto &scene : scenes) { scene.captured=false; scene.opticalCaptured=false; scene.surfaceCleared=false; scene.lavaPrepared=false; } +}''',''' for (auto &scene : scenes) { scene.captured=false; scene.opticalCaptured=false; scene.surfaceCleared=false; scene.lavaPrepared=false; } // The workload that recorded the caustic upload has completed. if (!causticPending) causticUpload.reset(); }''') - t=replace(t,' scene.materialSet = std::make_unique(worker->device);\n', - ' scene.materialSet = std::make_unique(worker->device,causticSampler.get());\n') + t=replace(t,' scene.materialSet=std::make_unique(worker->device);\n', + ' scene.materialSet=std::make_unique(worker->device,causticSampler.get());\n') t=replace(t,''' ms->setTexture(ms->sceneDepth,scene.depth.get(),RenderTextureLayout::SHADER_READ,scene.depthView.get()); ''',''' ms->setTexture(ms->sceneDepth,scene.depth.get(),RenderTextureLayout::SHADER_READ,scene.depthView.get()); ms->setTexture(ms->caustics,causticTexture.get(),RenderTextureLayout::SHADER_READ,causticView.get()); diff --git a/scripts/compose-water-lowpass.py b/scripts/compose-water-lowpass.py index 662133ec..4a53200c 100644 --- a/scripts/compose-water-lowpass.py +++ b/scripts/compose-water-lowpass.py @@ -2,7 +2,7 @@ Read the composed stage; emit one pipeline patch. Re-runs reverse this layer (and the later caustics layer, when present) in memory first, so the -patch is always authored against the reflection-search baseline. +patch is always authored against the lava-glow baseline. """ from pathlib import Path import difflib @@ -15,7 +15,7 @@ caustics=root/'patches/enhanced-water/dkr-caustics.patch' layers=[] if caustics.exists() and 'causticTexture' in (stage/'src/shaders/WaterPS.hlsl').read_text():layers.append(caustics) -if 'craftAir' in (stage/'src/shared/rt64_water_params.h').read_text():layers.append(output) +if 'WATER_CRAFT_AIR' in (stage/'src/shared/rt64_water_params.h').read_text():layers.append(output) changes=[] def replace(t,a,b): @@ -40,15 +40,28 @@ def params(t): #define WATER_LOWPASS_HOVER_SCALE 0.55f #define WATER_LOWPASS_FULL_SPEED 900.0f ''') - t=replace(t,''' float4 cameraPosition; // XYZ, W=1 marks the reviewed DKR adapter -};''',''' float4 cameraPosition; // XYZ, W=1 marks the reviewed DKR adapter - // Clearance above the liquid, four crafts per vector: positive values - // are aircraft, negative values hovercraft, zero means no low pass. - float4 craftAir[WATER_CRAFT_COUNT/4]; -};''') - t=replace(t,'''static_assert(sizeof(WaterMaterial) == 1552, "DKR water task ABI"); -static_assert(sizeof(WaterDrawParams) == 2000, "DKR water shader ABI");''','''static_assert(sizeof(WaterMaterial) == 1584, "DKR water task ABI"); -static_assert(sizeof(WaterDrawParams) == 2032, "DKR water shader ABI");''') + t=replace(t,''' float4 shoreSegments[WATER_CRAFT_COUNT * WATER_SHORE_SEGMENTS_PER_TILE]; // immutable mean-water intersections, XZ endpoints +''',''' // Water draws carry low-pass clearance in the last two shoreline vectors (WATER_CRAFT_AIR). + float4 shoreSegments[WATER_CRAFT_COUNT * WATER_SHORE_SEGMENTS_PER_TILE]; // immutable mean-water intersections, XZ endpoints +''') + t=replace(t,'''struct WaterDrawParams { +''','''// Low-pass clearance above the liquid, four crafts per vector: positive +// values are aircraft, negative values hovercraft, zero means no low pass. +// WaterDrawParams is exactly at the 2048-byte structured-buffer stride, so +// the clearance shares the last two shoreline vectors instead of growing it. +// Water draws publish no shoreline segments (every shoreCounts entry stays +// zero, so the field shaders never read them) and lava draws reuse only +// shoreSegments[0..15] for emitters and write no clearance. Low passes are +// gated on active crafts (W>=0), which lava and vertical water never have. +#define WATER_CRAFT_AIR_SEGMENT (WATER_CRAFT_COUNT * WATER_SHORE_SEGMENTS_PER_TILE - WATER_CRAFT_COUNT / 4) +#define WATER_CRAFT_AIR(m, i) ((m).shoreSegments[WATER_CRAFT_AIR_SEGMENT + (i) / 4][(i) % 4]) + +struct WaterDrawParams { +''') + t=replace(t,'''static_assert(sizeof(WaterDrawParams) <= 2048, "D3D structured-buffer stride limit"); +''','''static_assert(sizeof(WaterDrawParams) <= 2048, "D3D structured-buffer stride limit"); +static_assert(WATER_CRAFT_AIR_SEGMENT >= 16, "Low-pass clearance must not overlap the lava emitters"); +''') return t+''' #ifndef HLSL_CPU // Matches lowPassStrength in rt64_water_renderer.cpp. @@ -91,7 +104,7 @@ def renderer(t): // hull: outward curtains (kind 2) and slowly settling mist (kind 1). for (uint32_t i=0;i0;});''', ''' // A low pass over the liquid keeps the field alive without hull contact. bool lowPass=false; - for (uint32_t i=0;i=0 && m.craftAir[i/4][i%4]!=0; + for (uint32_t i=0;i=0 && WATER_CRAFT_AIR(m,i)!=0; const bool active=horizontal!=draws.end() && (lowPass || std::any_of(std::begin(m.craftCurrent),std::end(m.craftCurrent),[](const auto& c){return c.w>0;}));''') return t @@ -144,7 +157,7 @@ def field(t): t=replace(t,''' craftResponse[lane]=float4(saturate((old.y-current.y)/interval/180),landed ? 1 : 0,current.w,0); ''',''' craftResponse[lane]=float4(saturate((old.y-current.y)/interval/180),landed ? 1 : 0,current.w,0); // Low pass: a footprint trailing beneath the craft, wider when higher. - float air=m.craftAir[lane/4][lane%4]; + float air=WATER_CRAFT_AIR(m,lane); float clearance=abs(air); float wash=current.w>=0 ? waterLowPassStrength(air)*saturate(distance/interval/WATER_LOWPASS_FULL_SPEED) : 0; craftWash[lane]=float4(to-heading*(10+clearance*0.15),20+clearance*0.3,wash*min(length(to-from)/cell,2)); @@ -265,7 +278,7 @@ def surface(t): float interval=max(m.animation.y-m.animation.x,0.00001); float f=saturate((p.camera.w-m.animation.x)/interval); [loop] for (uint i=0;i Date: Sun, 8 Nov 2026 12:05:25 -0300 Subject: [PATCH 06/11] Fold the caustic texture layer into the lava-glow water patch main's pattern guard refuses new first-party patch layers, so the caustics changes now ride on the existing baselined dkr-lava-glow.patch instead of a separate dkr-caustics.patch, and the texture moves out of patches/ to runtime-recomp/assets/water. The low-pass layer is regenerated on top of it. The composed RT64 tree is byte-identical to the previous chain. --- patches/enhanced-water/dkr-caustics.patch | 234 ------------------ patches/enhanced-water/dkr-lava-glow.patch | 206 ++++++++++++++- patches/enhanced-water/dkr-lowpass.patch | 14 +- .../assets/water}/WaterCaustics.png | Bin runtime-recomp/cmake/EnhancedWater.cmake | 7 +- scripts/compose-water-caustics.py | 193 --------------- scripts/compose-water-lowpass.py | 6 +- 7 files changed, 212 insertions(+), 448 deletions(-) delete mode 100644 patches/enhanced-water/dkr-caustics.patch rename {patches/enhanced-water/native => runtime-recomp/assets/water}/WaterCaustics.png (100%) delete mode 100644 scripts/compose-water-caustics.py diff --git a/patches/enhanced-water/dkr-caustics.patch b/patches/enhanced-water/dkr-caustics.patch deleted file mode 100644 index 5f91f09a..00000000 --- a/patches/enhanced-water/dkr-caustics.patch +++ /dev/null @@ -1,234 +0,0 @@ -diff --git a/src/render/rt64_water_renderer.h b/src/render/rt64_water_renderer.h ---- a/src/render/rt64_water_renderer.h -+++ b/src/render/rt64_water_renderer.h -@@ -95,8 +95,8 @@ - } - }; - struct WaterDescriptorSet : RenderDescriptorSetBase { -- uint32_t draws, sceneColor, sceneDepth, currentField, previousField, originalColor; -- explicit WaterDescriptorSet(RenderDevice *device = nullptr) { -+ uint32_t draws, sceneColor, sceneDepth, currentField, previousField, originalColor, caustics; -+ explicit WaterDescriptorSet(RenderDevice *device = nullptr, const RenderSampler *causticSampler = nullptr) { - builder.begin(); - draws = builder.addStructuredBuffer(1); - sceneColor = builder.addTexture(2); -@@ -104,6 +104,10 @@ - currentField = builder.addTexture(4); - previousField = builder.addTexture(5); - originalColor = builder.addTexture(6); -+ caustics = builder.addTexture(7); -+ // Immutable samplers go last: D3D12 drops them from the root table but -+ // still counts them in the set, so any later range would read empty. -+ builder.addImmutableSampler(8, causticSampler); - builder.end(); - if (device) create(device); - } -@@ -113,6 +117,15 @@ - struct WaterRenderer { - std::vector draws; - BufferPair drawBuffer; -+ // Declared before every descriptor set: the immutable sampler and the -+ // texture must outlive the sets that sample them. -+ std::unique_ptr causticSampler; -+ std::unique_ptr causticTexture; -+ std::unique_ptr causticView; -+ std::unique_ptr causticUpload; -+ std::vector causticOffsets; -+ uint32_t causticSize=0; -+ bool causticPending=false; - std::unique_ptr descriptorSet; - std::unique_ptr layout; - struct WaterfallSet : RenderDescriptorSetBase { -@@ -259,6 +272,7 @@ - uint64_t allocationBytes() const; - void prepareField(RenderWorker *worker); - void recordField(RenderWorker *worker); -+ void recordCaustics(RenderWorker *worker); - void stepSpray(const interop::WaterMaterial &material, float previousTime, float nextTime, bool reset); - void beginSurface(RenderWorker *worker,uint32_t drawIndex); - void beginColor(RenderWorker *worker,uint32_t drawIndex); -diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer.cpp ---- a/src/render/rt64_water_renderer.cpp -+++ b/src/render/rt64_water_renderer.cpp -@@ -11,6 +11,8 @@ - #include - #include - #include -+#include "stb/stb_image.h" -+#include "textures/WaterCaustics.png.h" - #include "shaders/WaterVS.hlsl.spirv.h" - #include "shaders/WaterfallVS.hlsl.spirv.h" - #include "shaders/WaterfallPS.hlsl.spirv.h" -@@ -124,7 +126,53 @@ - waterfallVS=checkedWaterResource(device->createShader(CREATE_SHADER_INPUTS(WaterfallVSBlobDXIL,WaterfallVSBlobSPIRV,WaterfallVSBlobMSL,"VSMain",format))); - waterfallPS=checkedWaterResource(device->createShader(CREATE_SHADER_INPUTS(WaterfallPSBlobDXIL,WaterfallPSBlobSPIRV,WaterfallPSBlobMSL,"PSMain",format))); - waterfallMSPS=checkedWaterResource(device->createShader(CREATE_SHADER_INPUTS(WaterfallMSPSBlobDXIL,WaterfallMSPSBlobSPIRV,WaterfallMSPSBlobMSL,"PSMain",format))); -- descriptorSet = std::make_unique(device); -+ // The caustic pattern is an authored grayscale PNG embedded at build time. -+ // Box-filtered mips are staged here and uploaded once by recordCaustics. -+ RenderSamplerDesc causticDesc; -+ causticDesc.maxAnisotropy=getDefaultSamplerAnisotropy(); -+ causticDesc.anisotropyEnabled=causticDesc.maxAnisotropy>1; -+ causticSampler=checkedWaterResource(device->createSampler(causticDesc)); -+ int causticWidth=0,causticHeight=0,causticChannels=0; -+ stbi_uc *causticPixels=stbi_load_from_memory(reinterpret_cast(WaterCausticsPNG),int(sizeof(WaterCausticsPNG)), -+ &causticWidth,&causticHeight,&causticChannels,1); -+ if (!causticPixels || causticWidth<1 || causticWidth!=causticHeight || (causticWidth&(causticWidth-1))) { -+ stbi_image_free(causticPixels); -+ throw std::runtime_error("water caustic texture is invalid; retaining Original"); -+ } -+ causticSize=uint32_t(causticWidth); -+ std::vector> causticMips{std::vector(causticPixels,causticPixels+size_t(causticSize)*causticSize)}; -+ stbi_image_free(causticPixels); -+ for (uint32_t size=causticSize;size>1;size/=2) { -+ const auto &source=causticMips.back(); -+ const uint32_t half=size/2; -+ std::vector next(size_t(half)*half); -+ for (uint32_t y=0;y>level,1u); -+ causticBytes+=((size+255)&~255u)*size; -+ } -+ causticUpload=checkedWaterResource(device->createBuffer(RenderBufferDesc::UploadBuffer(causticBytes))); -+ auto *causticStaging=static_cast(causticUpload->map()); -+ std::memset(causticStaging,0,causticBytes); -+ for (uint32_t level=0;level>level,1u), pitch=(size+255)&~255u; -+ for (uint32_t y=0;yunmap(); -+ causticTexture=checkedWaterResource(device->createTexture(RenderTextureDesc::Texture2D(causticSize,causticSize,uint32_t(causticMips.size()),RenderFormat::R8_UNORM))); -+ causticView=checkedWaterResource(causticTexture->createTextureView(RenderTextureViewDesc::Texture2D(RenderFormat::R8_UNORM))); -+ causticTexture->setName("Water authored caustic pattern"); -+ causticPending=true; -+ descriptorSet = std::make_unique(device,causticSampler.get()); - RenderPipelineLayoutBuilder builder; - builder.begin(false, true); // Water vertices use the input assembler on D3D12. - builder.addPushConstant(0, 0, sizeof(interop::WaterPushConstants), RenderShaderStageFlag::VERTEX | RenderShaderStageFlag::PIXEL); -@@ -233,6 +281,8 @@ - wireIndices.clear(); wireStarts.clear(); - profileKinds.clear(); - for (auto &scene : scenes) { scene.captured=false; scene.opticalCaptured=false; scene.surfaceCleared=false; scene.lavaPrepared=false; } -+ // The workload that recorded the caustic upload has completed. -+ if (!causticPending) causticUpload.reset(); - } - - uint32_t WaterRenderer::prepareScene(RenderWorker *worker, RenderTarget *color, RenderTarget *depth, uint64_t key, bool classic, bool selfDepth,water_work::Region originalRegion) { -@@ -313,7 +363,7 @@ - } - } - scene.captureSet=std::make_unique(worker->device); -- scene.materialSet=std::make_unique(worker->device); -+ scene.materialSet=std::make_unique(worker->device,causticSampler.get()); - if(!classic) { - scene.surfaceView=checkedWaterResource(scene.surface->createTextureView(RenderTextureViewDesc::Texture2D(RenderFormat::R32_FLOAT))); - scene.surfaceWidth=width;scene.surfaceHeight=height;scene.spraySet=std::make_unique(worker->device); -@@ -348,6 +398,7 @@ - ms->setTexture(ms->originalColor,scene.color.get(),RenderTextureLayout::SHADER_READ,scene.colorView.get()); - ms->setTexture(ms->sceneColor,scene.color.get(),RenderTextureLayout::SHADER_READ,scene.colorView.get()); - ms->setTexture(ms->sceneDepth,scene.depth.get(),RenderTextureLayout::SHADER_READ,scene.depthView.get()); -+ ms->setTexture(ms->caustics,causticTexture.get(),RenderTextureLayout::SHADER_READ,causticView.get()); - if(!existing)sceneKeys.emplace(sceneKey,index); - return index; - } catch(...) { -@@ -756,6 +807,18 @@ - }); - endProfile(worker,token); - -+} -+ -+void WaterRenderer::recordCaustics(RenderWorker *worker) { -+ if (!causticPending) return; -+ worker->commandList->barriers(RenderBarrierStage::COPY,RenderTextureBarrier(causticTexture.get(),RenderTextureLayout::COPY_DEST)); -+ for (uint32_t level=0;level>level,1u); -+ worker->commandList->copyTextureRegion(RenderTextureCopyLocation::Subresource(causticTexture.get(),level), -+ RenderTextureCopyLocation::PlacedFootprint(causticUpload.get(),RenderFormat::R8_UNORM,size,size,1,(size+255)&~255u,causticOffsets[level])); -+ } -+ worker->commandList->barriers(RenderBarrierStage::GRAPHICS,RenderTextureBarrier(causticTexture.get(),RenderTextureLayout::SHADER_READ)); -+ causticPending=false; - } - - uint32_t WaterRenderer::beginProfile(RenderWorker *worker, uint32_t kind) { -diff --git a/src/render/rt64_framebuffer_renderer.cpp b/src/render/rt64_framebuffer_renderer.cpp ---- a/src/render/rt64_framebuffer_renderer.cpp -+++ b/src/render/rt64_framebuffer_renderer.cpp -@@ -1361,6 +1361,7 @@ - if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->rays.record(worker); - if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordNormals(worker, *outputBuffers); - if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordField(worker); -+ if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordCaustics(worker); - - if (vertexProcessor != nullptr) { - vertexProcessor->recordCommandList(worker, shaderLibrary, outputBuffers); -diff --git a/src/shaders/WaterPS.hlsl b/src/shaders/WaterPS.hlsl ---- a/src/shaders/WaterPS.hlsl -+++ b/src/shaders/WaterPS.hlsl -@@ -7,6 +7,8 @@ - Texture2D currentField : register(t4); - Texture2D previousField : register(t5); - Texture2D originalColor : register(t6); -+Texture2D causticTexture : register(t7); -+SamplerState causticSampler : register(s8); - - static const float PI = 3.14159265359; - -@@ -354,16 +356,18 @@ - float refractedThickness = opticalDepth(refractedPixel,position.z,worldPosition,p); - // Reject foreground silhouettes at the proposed sample before distorting. - if (refractedThickness < 0) { refractedPixel = position.xy; refractedThickness = thickness; } -- // Derivatives must be evaluated for the whole pixel quad, before the -- // depth-validity branch. A branch crossing a shoreline otherwise leaves -- // caustic receiver normals/filter widths undefined on some backends. -- float bottomDepth=sceneDepth.Load(int3(int2(boundPixel(refractedPixel,p)),0)); -- float3 bottom=waterWorldFromPixel(refractedPixel,bottomDepth,p); -- float3 bottomNormal=cross(ddx(bottom),ddy(bottom)); -- bottomNormal*=rsqrt(max(dot(bottomNormal,bottomNormal),0.000001)); -- if (bottomNormal.y<0) bottomNormal=-bottomNormal; -- float2 causticUV=bottom.xz*0.035; -- float causticFilter=1-smoothstep(0.25,1.5,length(fwidth(causticUV))); -+ // Caustics follow the reference water workflow: one authored pattern read -+ // twice on the water plane, panned in opposite directions, the second -+ // layer rescaled so the two never align. Mipmaps keep distant cells from -+ // breaking into stair-stepped lines. Sample before the depth-validity -+ // branch: a branch crossing a shoreline leaves derivatives undefined. -+ float2 causticUV=worldPosition.xz/130+ripples*0.6; -+ float caustic=causticTexture.Sample(causticSampler,causticUV+float2(-0.05,0.05)*p.camera.w) -+ +causticTexture.Sample(causticSampler,(causticUV+float2(0.05,-0.05)*p.camera.w)*0.66); -+ // Vertical water column under this pixel. Unlike the view-ray thickness it -+ // does not grow at grazing angles. Negative without a submerged surface. -+ float waterDepth=thickness>=0 ? max(worldPosition.y-waterWorldFromPixel(position.xy, -+ sceneDepth.Load(int3(int2(position.xy),0)),p).y,0) : -1; - if (thickness >= 0 && refractedThickness >= 0) { - float3 transmission = exp(-m.deepColor.a * float3(1.8,0.55,0.30) * refractedThickness); - // Course geometry often ends a short distance below the shoreline. -@@ -377,15 +381,12 @@ - // Uncovered taps transition to the water body, not an unrelated - // background color. Keep fractional coverage for a soft boundary. - transmission*=receiverColor.a; -- // Stable world-space light focusing, confined to an actual shallow -- // submerged receiver. Dark source lighting and steep walls reject it. -- float phaseA=causticUV.x+sin(causticUV.y*1.3+p.camera.w*0.65); -- float phaseB=causticUV.y-sin(causticUV.x*1.1-p.camera.w*0.5); -- float focusing=pow(saturate(1-abs(sin(phaseA)+sin(phaseB)*0.55)),12); -- float receiver=smoothstep(0.2,0.65,bottomNormal.y)*saturate(dot(bottomNormal,L)); -+ // Focused light only in shallow water and near the camera, and never -+ // on receivers whose source lighting is dark. -+ float receiver=smoothstep(0,2,waterDepth)*(1-smoothstep(0,60,waterDepth)); -+ receiver*=1-smoothstep(900,2200,length(p.camera.xyz-worldPosition)); - receiver*=smoothstep(0.015,0.08,dot(bottomColor,float3(0.2126,0.7152,0.0722))); -- receiver*=bottom.y previousField : register(t5); + Texture2D originalColor : register(t6); ++Texture2D causticTexture : register(t7); ++SamplerState causticSampler : register(s8); + + static const float PI = 3.14159265359; +@@ -250,4 +252,8 @@ float footprint=max(length(ddx(worldPosition.xz)),length(ddy(worldPosition.xz)))*LAVA_WORLD_SCALE; float4 molten=lavaSurface(worldPosition.xz,p.camera.w,footprint); + // Surface heat and nearby glow share the interpolated clock. Pulse @@ -152,27 +159,208 @@ diff --git a/src/shaders/WaterPS.hlsl b/src/shaders/WaterPS.hlsl + molten.rgb=lavaHeatPalette(molten.rgb,cooling)*m.shoreCounts[0].z; float roughness=clamp(m.shallowColor.a+molten.a*0.18,0.065,0.65); float4 reflection=0; -@@ -255,5 +259,5 @@ +@@ -255,5 +261,5 @@ #ifdef HARDWARE_WATER bool fallingHit=false,capturedHit=false; - float4 traced=hardwareWaterReflection(worldPosition,N,V,p,fallingHit,capturedHit); + float4 traced=m.shoreCounts[0].y>0.5 ? hardwareWaterReflection(worldPosition,N,V,p,fallingHit,capturedHit) : float4(0,0,0,0); if(fallingHit || capturedHit)reflection=traced; else { +@@ -319,14 +325,16 @@ + // Reject foreground silhouettes at the proposed sample before distorting. + if (refractedThickness < 0) { refractedPixel = position.xy; refractedThickness = thickness; } +- // Derivatives must be evaluated for the whole pixel quad, before the +- // depth-validity branch. A branch crossing a shoreline otherwise leaves +- // caustic receiver normals/filter widths undefined on some backends. +- float bottomDepth=sceneDepth.Load(int3(int2(boundPixel(refractedPixel,p)),0)); +- float3 bottom=waterWorldFromPixel(refractedPixel,bottomDepth,p); +- float3 bottomNormal=cross(ddx(bottom),ddy(bottom)); +- bottomNormal*=rsqrt(max(dot(bottomNormal,bottomNormal),0.000001)); +- if (bottomNormal.y<0) bottomNormal=-bottomNormal; +- float2 causticUV=bottom.xz*0.035; +- float causticFilter=1-smoothstep(0.25,1.5,length(fwidth(causticUV))); ++ // Caustics follow the reference water workflow: one authored pattern read ++ // twice on the water plane, panned in opposite directions, the second ++ // layer rescaled so the two never align. Mipmaps keep distant cells from ++ // breaking into stair-stepped lines. Sample before the depth-validity ++ // branch: a branch crossing a shoreline leaves derivatives undefined. ++ float2 causticUV=worldPosition.xz/130+ripples*0.6; ++ float caustic=causticTexture.Sample(causticSampler,causticUV+float2(-0.05,0.05)*p.camera.w) ++ +causticTexture.Sample(causticSampler,(causticUV+float2(0.05,-0.05)*p.camera.w)*0.66); ++ // Vertical water column under this pixel. Unlike the view-ray thickness it ++ // does not grow at grazing angles. Negative without a submerged surface. ++ float waterDepth=thickness>=0 ? max(worldPosition.y-waterWorldFromPixel(position.xy, ++ sceneDepth.Load(int3(int2(position.xy),0)),p).y,0) : -1; + if (thickness >= 0 && refractedThickness >= 0) { + float3 transmission = exp(-m.deepColor.a * float3(1.8,0.55,0.30) * refractedThickness); +@@ -342,13 +350,10 @@ + // background color. Keep fractional coverage for a soft boundary. + transmission*=receiverColor.a; +- // Stable world-space light focusing, confined to an actual shallow +- // submerged receiver. Dark source lighting and steep walls reject it. +- float phaseA=causticUV.x+sin(causticUV.y*1.3+p.camera.w*0.65); +- float phaseB=causticUV.y-sin(causticUV.x*1.1-p.camera.w*0.5); +- float focusing=pow(saturate(1-abs(sin(phaseA)+sin(phaseB)*0.55)),12); +- float receiver=smoothstep(0.2,0.65,bottomNormal.y)*saturate(dot(bottomNormal,L)); ++ // Focused light only in shallow water and near the camera, and never ++ // on receivers whose source lighting is dark. ++ float receiver=smoothstep(0,2,waterDepth)*(1-smoothstep(0,60,waterDepth)); ++ receiver*=1-smoothstep(900,2200,length(p.camera.xyz-worldPosition)); + receiver*=smoothstep(0.015,0.08,dot(bottomColor,float3(0.2126,0.7152,0.0722))); +- receiver*=bottom.y draws; + BufferPair drawBuffer; ++ // Declared before every descriptor set: the immutable sampler and the ++ // texture must outlive the sets that sample them. ++ std::unique_ptr causticSampler; ++ std::unique_ptr causticTexture; ++ std::unique_ptr causticView; ++ std::unique_ptr causticUpload; ++ std::vector causticOffsets; ++ uint32_t causticSize=0; ++ bool causticPending=false; + std::unique_ptr descriptorSet; + std::unique_ptr layout; +@@ -239,5 +252,5 @@ std::vector profileKinds; uint32_t profileFrames=0; - std::array profileTotals{}; + std::array profileTotals{}; bool usesHDR; +@@ -259,4 +272,5 @@ + void prepareField(RenderWorker *worker); + void recordField(RenderWorker *worker); ++ void recordCaustics(RenderWorker *worker); + void stepSpray(const interop::WaterMaterial &material, float previousTime, float nextTime, bool reset); + void beginSurface(RenderWorker *worker,uint32_t drawIndex); diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer.cpp --- a/src/render/rt64_water_renderer.cpp +++ b/src/render/rt64_water_renderer.cpp -@@ -744,14 +744,14 @@ +@@ -12,4 +12,6 @@ + #include + #include ++#include "stb/stb_image.h" ++#include "textures/WaterCaustics.png.h" + #include "shaders/WaterVS.hlsl.spirv.h" + #include "shaders/WaterfallVS.hlsl.spirv.h" +@@ -125,5 +127,51 @@ + waterfallPS=checkedWaterResource(device->createShader(CREATE_SHADER_INPUTS(WaterfallPSBlobDXIL,WaterfallPSBlobSPIRV,WaterfallPSBlobMSL,"PSMain",format))); + waterfallMSPS=checkedWaterResource(device->createShader(CREATE_SHADER_INPUTS(WaterfallMSPSBlobDXIL,WaterfallMSPSBlobSPIRV,WaterfallMSPSBlobMSL,"PSMain",format))); +- descriptorSet = std::make_unique(device); ++ // The caustic pattern is an authored grayscale PNG embedded at build time. ++ // Box-filtered mips are staged here and uploaded once by recordCaustics. ++ RenderSamplerDesc causticDesc; ++ causticDesc.maxAnisotropy=getDefaultSamplerAnisotropy(); ++ causticDesc.anisotropyEnabled=causticDesc.maxAnisotropy>1; ++ causticSampler=checkedWaterResource(device->createSampler(causticDesc)); ++ int causticWidth=0,causticHeight=0,causticChannels=0; ++ stbi_uc *causticPixels=stbi_load_from_memory(reinterpret_cast(WaterCausticsPNG),int(sizeof(WaterCausticsPNG)), ++ &causticWidth,&causticHeight,&causticChannels,1); ++ if (!causticPixels || causticWidth<1 || causticWidth!=causticHeight || (causticWidth&(causticWidth-1))) { ++ stbi_image_free(causticPixels); ++ throw std::runtime_error("water caustic texture is invalid; retaining Original"); ++ } ++ causticSize=uint32_t(causticWidth); ++ std::vector> causticMips{std::vector(causticPixels,causticPixels+size_t(causticSize)*causticSize)}; ++ stbi_image_free(causticPixels); ++ for (uint32_t size=causticSize;size>1;size/=2) { ++ const auto &source=causticMips.back(); ++ const uint32_t half=size/2; ++ std::vector next(size_t(half)*half); ++ for (uint32_t y=0;y>level,1u); ++ causticBytes+=((size+255)&~255u)*size; ++ } ++ causticUpload=checkedWaterResource(device->createBuffer(RenderBufferDesc::UploadBuffer(causticBytes))); ++ auto *causticStaging=static_cast(causticUpload->map()); ++ std::memset(causticStaging,0,causticBytes); ++ for (uint32_t level=0;level>level,1u), pitch=(size+255)&~255u; ++ for (uint32_t y=0;yunmap(); ++ causticTexture=checkedWaterResource(device->createTexture(RenderTextureDesc::Texture2D(causticSize,causticSize,uint32_t(causticMips.size()),RenderFormat::R8_UNORM))); ++ causticView=checkedWaterResource(causticTexture->createTextureView(RenderTextureViewDesc::Texture2D(RenderFormat::R8_UNORM))); ++ causticTexture->setName("Water authored caustic pattern"); ++ causticPending=true; ++ descriptorSet = std::make_unique(device,causticSampler.get()); + RenderPipelineLayoutBuilder builder; + builder.begin(false, true); // Water vertices use the input assembler on D3D12. +@@ -234,4 +282,6 @@ + profileKinds.clear(); + for (auto &scene : scenes) { scene.captured=false; scene.opticalCaptured=false; scene.surfaceCleared=false; scene.lavaPrepared=false; } ++ // The workload that recorded the caustic upload has completed. ++ if (!causticPending) causticUpload.reset(); + } + +@@ -314,5 +364,5 @@ + } + scene.captureSet=std::make_unique(worker->device); +- scene.materialSet=std::make_unique(worker->device); ++ scene.materialSet=std::make_unique(worker->device,causticSampler.get()); + if(!classic) { + scene.surfaceView=checkedWaterResource(scene.surface->createTextureView(RenderTextureViewDesc::Texture2D(RenderFormat::R32_FLOAT))); +@@ -349,4 +399,5 @@ + ms->setTexture(ms->sceneColor,scene.color.get(),RenderTextureLayout::SHADER_READ,scene.colorView.get()); + ms->setTexture(ms->sceneDepth,scene.depth.get(),RenderTextureLayout::SHADER_READ,scene.depthView.get()); ++ ms->setTexture(ms->caustics,causticTexture.get(),RenderTextureLayout::SHADER_READ,causticView.get()); + if(!existing)sceneKeys.emplace(sceneKey,index); + return index; +@@ -705,4 +756,16 @@ + } + ++void WaterRenderer::recordCaustics(RenderWorker *worker) { ++ if (!causticPending) return; ++ worker->commandList->barriers(RenderBarrierStage::COPY,RenderTextureBarrier(causticTexture.get(),RenderTextureLayout::COPY_DEST)); ++ for (uint32_t level=0;level>level,1u); ++ worker->commandList->copyTextureRegion(RenderTextureCopyLocation::Subresource(causticTexture.get(),level), ++ RenderTextureCopyLocation::PlacedFootprint(causticUpload.get(),RenderFormat::R8_UNORM,size,size,1,(size+255)&~255u,causticOffsets[level])); ++ } ++ worker->commandList->barriers(RenderBarrierStage::GRAPHICS,RenderTextureBarrier(causticTexture.get(),RenderTextureLayout::SHADER_READ)); ++ causticPending=false; ++} ++ + uint32_t WaterRenderer::beginProfile(RenderWorker *worker, uint32_t kind) { + if (!profilePool || profileKinds.size() >= 64) return UINT32_MAX; +@@ -744,14 +807,14 @@ profilePool->queryResults(); const auto *times = profilePool->getResults(); - double milliseconds[4] = {}; @@ -192,7 +380,7 @@ diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer + profileTotals[3]/profileFrames,profileTotals[4]/profileFrames,profileTotals[5]/profileFrames,static_cast(allocationBytes())); profileFrames=0;profileTotals.fill(0); } -@@ -759,11 +759,11 @@ +@@ -759,11 +822,11 @@ const char *name = std::getenv("DKRR_WATER_PROFILE"); FILE *file = name ? std::fopen(name,"w") : nullptr; - if (file) std::fprintf(file,"course,quality,debug,views,width,height,water_draws,normal_vertices,normal_ms,capture_ms,surface_ms,field_ms,frame_gpu_ms\n"); @@ -210,7 +398,7 @@ diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer diff --git a/src/render/rt64_framebuffer_renderer.cpp b/src/render/rt64_framebuffer_renderer.cpp --- a/src/render/rt64_framebuffer_renderer.cpp +++ b/src/render/rt64_framebuffer_renderer.cpp -@@ -549,8 +549,10 @@ +@@ -550,8 +550,10 @@ if(pendingLava==UINT32_MAX)return; finishWater(); + const auto lavaProfileToken=waterRenderer->beginProfile(worker,4); @@ -221,3 +409,9 @@ diff --git a/src/render/rt64_framebuffer_renderer.cpp b/src/render/rt64_framebuf + waterRenderer->endProfile(worker,lavaProfileToken); pendingLava=UINT32_MAX;pendingLavaProjection=UINT32_MAX; switchToGraphicsPipeline(); +@@ -1360,4 +1362,5 @@ + if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordNormals(worker, *outputBuffers); + if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordField(worker); ++ if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordCaustics(worker); + + if (vertexProcessor != nullptr) { diff --git a/patches/enhanced-water/dkr-lowpass.patch b/patches/enhanced-water/dkr-lowpass.patch index 2e6fed9b..36074e86 100644 --- a/patches/enhanced-water/dkr-lowpass.patch +++ b/patches/enhanced-water/dkr-lowpass.patch @@ -63,7 +63,7 @@ diff --git a/src/shared/rt64_water_params.h b/src/shared/rt64_water_params.h diff --git a/src/render/rt64_water_renderer.h b/src/render/rt64_water_renderer.h --- a/src/render/rt64_water_renderer.h +++ b/src/render/rt64_water_renderer.h -@@ -155,6 +155,7 @@ +@@ -168,6 +168,7 @@ BufferPair particleBuffer; std::array sprayDistance{}; std::array sprayHeading{}; @@ -74,7 +74,7 @@ diff --git a/src/render/rt64_water_renderer.h b/src/render/rt64_water_renderer.h diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer.cpp --- a/src/render/rt64_water_renderer.cpp +++ b/src/render/rt64_water_renderer.cpp -@@ -498,9 +498,20 @@ +@@ -549,9 +549,20 @@ worker->commandList->setFramebuffer(scene.waterColorFramebuffer.get()); } @@ -96,7 +96,7 @@ diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer spraySeed=0x57A064u^uint32_t(m.identity.w); return; } -@@ -536,6 +547,45 @@ +@@ -587,6 +598,45 @@ particles.push_back(p); } } @@ -142,7 +142,7 @@ diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer } void WaterRenderer::drawSpray(RenderWorker *worker,uint32_t drawIndex) { -@@ -576,7 +626,11 @@ +@@ -627,7 +677,11 @@ const auto horizontal=std::find_if(draws.begin(),draws.end(),[](const auto& d){return d.material.effects.z<3;}); const auto sourceDraw=horizontal==draws.end() ? draws.front() : *horizontal; const auto m=sourceDraw.material; @@ -316,7 +316,7 @@ diff --git a/src/shaders/WaterSprayPS.hlsl b/src/shaders/WaterSprayPS.hlsl diff --git a/src/shaders/WaterPS.hlsl b/src/shaders/WaterPS.hlsl --- a/src/shaders/WaterPS.hlsl +++ b/src/shaders/WaterPS.hlsl -@@ -299,6 +299,38 @@ +@@ -301,6 +301,38 @@ float determinant=dx.x*dy.y-dx.y*dy.x; float2 wakeGradient=float2(ddx(wake.x)*dy.y-ddy(wake.x)*dx.y,ddy(wake.x)*dx.x-ddx(wake.x)*dy.x)/max(abs(determinant),0.001)*sign(determinant); ripples += clamp(wakeGradient*8,-0.10,0.10); @@ -355,7 +355,7 @@ diff --git a/src/shaders/WaterPS.hlsl b/src/shaders/WaterPS.hlsl float3 N = normalize(geometric + float3(-ripples.x, 0, -ripples.y)); float3 V = normalize(p.camera.xyz - worldPosition); float3 L = normalize(m.sunDirection.xyz); -@@ -308,7 +340,7 @@ +@@ -310,7 +342,7 @@ float nh = saturate(dot(N,H)); float vh = saturate(dot(V,H)); float fresnel = 0.0204 + 0.9796 * pow(1-nv, 5); @@ -364,7 +364,7 @@ diff --git a/src/shaders/WaterPS.hlsl b/src/shaders/WaterPS.hlsl float a2 = pow(roughness, 4); float d = a2 / max(PI * pow(nh*nh*(a2-1)+1, 2), 0.00001); float k = pow(roughness + 1, 2) * 0.125; -@@ -404,7 +436,15 @@ +@@ -405,7 +437,15 @@ float breakup=foamBreakup(worldPosition.xz,p.camera.w,m.surface.yz); float shore=wake.z*(1-smoothstep(30,100,abs(worldPosition.y-m.surface.x))); float foam=saturate((wake.y*1.8+shore*1.3+crest+contact)*m.animation.w*breakup); diff --git a/patches/enhanced-water/native/WaterCaustics.png b/runtime-recomp/assets/water/WaterCaustics.png similarity index 100% rename from patches/enhanced-water/native/WaterCaustics.png rename to runtime-recomp/assets/water/WaterCaustics.png diff --git a/runtime-recomp/cmake/EnhancedWater.cmake b/runtime-recomp/cmake/EnhancedWater.cmake index 583f6d1a..4f2d5a51 100644 --- a/runtime-recomp/cmake/EnhancedWater.cmake +++ b/runtime-recomp/cmake/EnhancedWater.cmake @@ -42,8 +42,7 @@ set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${water_waterfal set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${water_stability}") set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${water_performance}" "${water_reflection_search}") set(water_lowpass "${DKRPORT_ROOT}/patches/enhanced-water/dkr-lowpass.patch") -set(water_caustics "${DKRPORT_ROOT}/patches/enhanced-water/dkr-caustics.patch") -set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${water_lowpass}" "${water_caustics}") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${water_lowpass}") set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${water_patch}" "${water_adapter}" "${water_budget}" "${water_safety}" "${water_views}" "${water_field_portability}" "${water_rays}" "${water_ray_caps}" "${water_projection}" "${water_recovery}") # Compose the capability adapter on the ACTIVE backend sources, including @@ -154,7 +153,7 @@ foreach(file IN ITEMS WaterProjection.hlsli WaterProjectionProbeCS.hlsl) configure_file("${DKRPORT_ROOT}/patches/enhanced-water/native/${file}" "${water_scratch}/src/shaders/${file}" COPYONLY) endforeach() execute_process(COMMAND "${GIT_EXECUTABLE}" init --quiet "${water_scratch}" COMMAND_ERROR_IS_FATAL ANY) -foreach(patch IN ITEMS "${water_patch}" "${water_adapter}" "${water_budget}" "${water_safety}" "${water_views}" "${water_field_portability}" "${water_rays}" "${water_ray_caps}" "${water_projection}" "${water_recovery}" "${water_performance}" "${water_reflection_search}" "${water_stability}" "${water_waterfall}" "${water_lava}" "${water_lava_lighting}" "${water_lava_glow}" "${water_lowpass}" "${water_caustics}") +foreach(patch IN ITEMS "${water_patch}" "${water_adapter}" "${water_budget}" "${water_safety}" "${water_views}" "${water_field_portability}" "${water_rays}" "${water_ray_caps}" "${water_projection}" "${water_recovery}" "${water_performance}" "${water_reflection_search}" "${water_stability}" "${water_waterfall}" "${water_lava}" "${water_lava_lighting}" "${water_lava_glow}" "${water_lowpass}") foreach(mode IN ITEMS check apply) set(flags --recount --ignore-space-change --whitespace=error) if(mode STREQUAL "check") @@ -285,7 +284,7 @@ dkr_water_shader(WaterSprayPS WaterSprayPS ps PSMain) dkr_water_shader(WaterSprayMSPS WaterSprayPS ps PSMain -D SPRAY_MSAA) # The authored caustic pattern is embedded like the shader blobs, so no # runtime file is packaged or looked up. -set(water_caustics_png "${DKRPORT_ROOT}/patches/enhanced-water/native/WaterCaustics.png") +set(water_caustics_png "${DKRPORT_ROOT}/runtime-recomp/assets/water/WaterCaustics.png") set(water_caustics_out "${water_shader_out}/textures/WaterCaustics.png") add_custom_command(OUTPUT "${water_caustics_out}.c" "${water_caustics_out}.h" COMMAND file_to_c "${water_caustics_png}" WaterCausticsPNG "${water_caustics_out}.c" "${water_caustics_out}.h" diff --git a/scripts/compose-water-caustics.py b/scripts/compose-water-caustics.py deleted file mode 100644 index 57ac66e8..00000000 --- a/scripts/compose-water-caustics.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Author the caustic texture layer without editing dependencies. - -Read the fully composed stage; emit one final pipeline patch. Re-runs reverse -only this layer in memory. The caustic PNG is an authored native input that -EnhancedWater.cmake embeds with the dependency's own file_to_c tool. -""" -from pathlib import Path -import difflib -import sys -from water_patch_baseline import read_before - -root=Path(__file__).resolve().parents[1] -stage=Path(sys.argv[1]).resolve() -output=root/'patches/enhanced-water/dkr-caustics.patch' -layers=[output] if 'causticTexture' in (stage/'src/shaders/WaterPS.hlsl').read_text() else [] -changes=[] - -def replace(text,old,new): - if text.count(old)!=1: - raise SystemExit(f'Non-unique caustics context: {old[:120]}') - return text.replace(old,new,1) - -def change(path,fn): - before=read_before(stage,path,layers) - after=fn(before) - if before==after:raise SystemExit(f'No caustics changes: {path}') - changes.append(f'diff --git a/{path} b/{path}\n') - changes.extend(difflib.unified_diff(before.splitlines(True),after.splitlines(True), - fromfile=f'a/{path}',tofile=f'b/{path}',n=3)) - -def header(t): - t=replace(t,''' uint32_t draws, sceneColor, sceneDepth, currentField, previousField, originalColor; - explicit WaterDescriptorSet(RenderDevice *device = nullptr) {''', - ''' uint32_t draws, sceneColor, sceneDepth, currentField, previousField, originalColor, caustics; - explicit WaterDescriptorSet(RenderDevice *device = nullptr, const RenderSampler *causticSampler = nullptr) {''') - t=replace(t,''' originalColor = builder.addTexture(6); - builder.end();''',''' originalColor = builder.addTexture(6); - caustics = builder.addTexture(7); - // Immutable samplers go last: D3D12 drops them from the root table but - // still counts them in the set, so any later range would read empty. - builder.addImmutableSampler(8, causticSampler); - builder.end();''') - t=replace(t,''' BufferPair drawBuffer; - std::unique_ptr descriptorSet;''',''' BufferPair drawBuffer; - // Declared before every descriptor set: the immutable sampler and the - // texture must outlive the sets that sample them. - std::unique_ptr causticSampler; - std::unique_ptr causticTexture; - std::unique_ptr causticView; - std::unique_ptr causticUpload; - std::vector causticOffsets; - uint32_t causticSize=0; - bool causticPending=false; - std::unique_ptr descriptorSet;''') - return replace(t,' void recordField(RenderWorker *worker);\n', - ' void recordField(RenderWorker *worker);\n void recordCaustics(RenderWorker *worker);\n') -change('src/render/rt64_water_renderer.h',header) - -def renderer(t): - t=replace(t,'#include \n','''#include -#include "stb/stb_image.h" -#include "textures/WaterCaustics.png.h" -''') - t=replace(t,''' descriptorSet = std::make_unique(device); - RenderPipelineLayoutBuilder builder;''',''' // The caustic pattern is an authored grayscale PNG embedded at build time. - // Box-filtered mips are staged here and uploaded once by recordCaustics. - RenderSamplerDesc causticDesc; - causticDesc.maxAnisotropy=getDefaultSamplerAnisotropy(); - causticDesc.anisotropyEnabled=causticDesc.maxAnisotropy>1; - causticSampler=checkedWaterResource(device->createSampler(causticDesc)); - int causticWidth=0,causticHeight=0,causticChannels=0; - stbi_uc *causticPixels=stbi_load_from_memory(reinterpret_cast(WaterCausticsPNG),int(sizeof(WaterCausticsPNG)), - &causticWidth,&causticHeight,&causticChannels,1); - if (!causticPixels || causticWidth<1 || causticWidth!=causticHeight || (causticWidth&(causticWidth-1))) { - stbi_image_free(causticPixels); - throw std::runtime_error("water caustic texture is invalid; retaining Original"); - } - causticSize=uint32_t(causticWidth); - std::vector> causticMips{std::vector(causticPixels,causticPixels+size_t(causticSize)*causticSize)}; - stbi_image_free(causticPixels); - for (uint32_t size=causticSize;size>1;size/=2) { - const auto &source=causticMips.back(); - const uint32_t half=size/2; - std::vector next(size_t(half)*half); - for (uint32_t y=0;y>level,1u); - causticBytes+=((size+255)&~255u)*size; - } - causticUpload=checkedWaterResource(device->createBuffer(RenderBufferDesc::UploadBuffer(causticBytes))); - auto *causticStaging=static_cast(causticUpload->map()); - std::memset(causticStaging,0,causticBytes); - for (uint32_t level=0;level>level,1u), pitch=(size+255)&~255u; - for (uint32_t y=0;yunmap(); - causticTexture=checkedWaterResource(device->createTexture(RenderTextureDesc::Texture2D(causticSize,causticSize,uint32_t(causticMips.size()),RenderFormat::R8_UNORM))); - causticView=checkedWaterResource(causticTexture->createTextureView(RenderTextureViewDesc::Texture2D(RenderFormat::R8_UNORM))); - causticTexture->setName("Water authored caustic pattern"); - causticPending=true; - descriptorSet = std::make_unique(device,causticSampler.get()); - RenderPipelineLayoutBuilder builder;''') - t=replace(t,''' for (auto &scene : scenes) { scene.captured=false; scene.opticalCaptured=false; scene.surfaceCleared=false; scene.lavaPrepared=false; } -}''',''' for (auto &scene : scenes) { scene.captured=false; scene.opticalCaptured=false; scene.surfaceCleared=false; scene.lavaPrepared=false; } - // The workload that recorded the caustic upload has completed. - if (!causticPending) causticUpload.reset(); -}''') - t=replace(t,' scene.materialSet=std::make_unique(worker->device);\n', - ' scene.materialSet=std::make_unique(worker->device,causticSampler.get());\n') - t=replace(t,''' ms->setTexture(ms->sceneDepth,scene.depth.get(),RenderTextureLayout::SHADER_READ,scene.depthView.get()); -''',''' ms->setTexture(ms->sceneDepth,scene.depth.get(),RenderTextureLayout::SHADER_READ,scene.depthView.get()); - ms->setTexture(ms->caustics,causticTexture.get(),RenderTextureLayout::SHADER_READ,causticView.get()); -''') - return replace(t,'''uint32_t WaterRenderer::beginProfile(RenderWorker *worker, uint32_t kind) {''','''void WaterRenderer::recordCaustics(RenderWorker *worker) { - if (!causticPending) return; - worker->commandList->barriers(RenderBarrierStage::COPY,RenderTextureBarrier(causticTexture.get(),RenderTextureLayout::COPY_DEST)); - for (uint32_t level=0;level>level,1u); - worker->commandList->copyTextureRegion(RenderTextureCopyLocation::Subresource(causticTexture.get(),level), - RenderTextureCopyLocation::PlacedFootprint(causticUpload.get(),RenderFormat::R8_UNORM,size,size,1,(size+255)&~255u,causticOffsets[level])); - } - worker->commandList->barriers(RenderBarrierStage::GRAPHICS,RenderTextureBarrier(causticTexture.get(),RenderTextureLayout::SHADER_READ)); - causticPending=false; -} - -uint32_t WaterRenderer::beginProfile(RenderWorker *worker, uint32_t kind) {''') -change('src/render/rt64_water_renderer.cpp',renderer) - -def framebuffer(t): - return replace(t,''' if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordField(worker); -''',''' if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordField(worker); - if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordCaustics(worker); -''') -change('src/render/rt64_framebuffer_renderer.cpp',framebuffer) - -def pixels(t): - t=replace(t,'Texture2D originalColor : register(t6);\n','''Texture2D originalColor : register(t6); -Texture2D causticTexture : register(t7); -SamplerState causticSampler : register(s8); -''') - t=replace(t,''' // Derivatives must be evaluated for the whole pixel quad, before the - // depth-validity branch. A branch crossing a shoreline otherwise leaves - // caustic receiver normals/filter widths undefined on some backends. - float bottomDepth=sceneDepth.Load(int3(int2(boundPixel(refractedPixel,p)),0)); - float3 bottom=waterWorldFromPixel(refractedPixel,bottomDepth,p); - float3 bottomNormal=cross(ddx(bottom),ddy(bottom)); - bottomNormal*=rsqrt(max(dot(bottomNormal,bottomNormal),0.000001)); - if (bottomNormal.y<0) bottomNormal=-bottomNormal; - float2 causticUV=bottom.xz*0.035; - float causticFilter=1-smoothstep(0.25,1.5,length(fwidth(causticUV))); -''',''' // Caustics follow the reference water workflow: one authored pattern read - // twice on the water plane, panned in opposite directions, the second - // layer rescaled so the two never align. Mipmaps keep distant cells from - // breaking into stair-stepped lines. Sample before the depth-validity - // branch: a branch crossing a shoreline leaves derivatives undefined. - float2 causticUV=worldPosition.xz/130+ripples*0.6; - float caustic=causticTexture.Sample(causticSampler,causticUV+float2(-0.05,0.05)*p.camera.w) - +causticTexture.Sample(causticSampler,(causticUV+float2(0.05,-0.05)*p.camera.w)*0.66); - // Vertical water column under this pixel. Unlike the view-ray thickness it - // does not grow at grazing angles. Negative without a submerged surface. - float waterDepth=thickness>=0 ? max(worldPosition.y-waterWorldFromPixel(position.xy, - sceneDepth.Load(int3(int2(position.xy),0)),p).y,0) : -1; -''') - return replace(t,''' // Stable world-space light focusing, confined to an actual shallow - // submerged receiver. Dark source lighting and steep walls reject it. - float phaseA=causticUV.x+sin(causticUV.y*1.3+p.camera.w*0.65); - float phaseB=causticUV.y-sin(causticUV.x*1.1-p.camera.w*0.5); - float focusing=pow(saturate(1-abs(sin(phaseA)+sin(phaseB)*0.55)),12); - float receiver=smoothstep(0.2,0.65,bottomNormal.y)*saturate(dot(bottomNormal,L)); - receiver*=smoothstep(0.015,0.08,dot(bottomColor,float3(0.2126,0.7152,0.0722))); - receiver*=bottom.y Date: Sun, 8 Nov 2026 19:26:44 -0300 Subject: [PATCH 07/11] Play Track Lab challenge, boss and hub levels from a .dkrmap Custom boss levels skip the retail boss intro lookup, which ran past the intro table for a custom level id. Hub EXITs can lead to another installed .dkrmap through manifest levelLinks, resolved after the object map is inflated. Tracks can carry up to 127 checkpoints. Shadow clipping on dense custom floors now respects the retail array sizes instead of overflowing them. A plain runtime configure links again through a retail pass-through for dkr_analogue_driving when the overlay does not define it. --- CHANGELOG.md | 13 ++ docs/CUSTOM_TRACKS.md | 97 +++++++++++ runtime-recomp/dkr.us.v77.recomp-policy.json | 30 ++++ runtime-recomp/dkr.us.v80.recomp-policy.json | 30 ++++ runtime-recomp/src/game/custom_tracks.cpp | 95 +++++++++++ runtime-recomp/src/game/custom_tracks.hpp | 29 ++++ .../src/game/custom_tracks_hooks.cpp | 157 ++++++++++++++++++ .../src/game/revision_addresses.hpp | 15 ++ runtime-recomp/src/game/runtime_code_mods.cpp | 14 ++ runtime-recomp/tests/custom_tracks_tests.cpp | 61 +++++++ 10 files changed, 541 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2be297d4..4b31873f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +- Custom tracks can carry up to 127 checkpoints per vehicle set, up from + retail's 60, for finer AI racing lines. Two recomp-policy instruction patches + grow the checkpoint table and its load bound in both revisions. +- Track Lab boss races start and end: a `.dkrmap` boss level no longer loads an + unrelated level in place of the retail intro and outro scenes, whose lookup + table holds only retail level ids. +- Shadows can no longer overflow the game's fixed shadow arrays. A custom floor + cut into many small faces under a racer used to draw stray triangles and then + crash; the shadow now drops the pieces that do not fit. +- A hub's exits can lead to other `.dkrmap` tracks. The package names the + destination track in `levelLinks`, and the runtime writes its level id into + the exit when the hub's object map loads. + ## 1.0.14 — 2026-11-04 - Manual Play now fades into the shared Starting Game screen with inline mod diff --git a/docs/CUSTOM_TRACKS.md b/docs/CUSTOM_TRACKS.md index 0e5c8f3b..0689729e 100644 --- a/docs/CUSTOM_TRACKS.md +++ b/docs/CUSTOM_TRACKS.md @@ -208,6 +208,53 @@ A hub gate is two separate objects in a level's object map, not one: `destinationMapId` is a `u8`. Retail uses 34 level ids, so roughly 220 remain. +An exit fires for a player inside its radius (the raw byte, in units; at least +5) who has crossed the plane through the exit facing its `angleY` +(`obj_loop_exit`, `object_functions.c:2441-2457`). Its bytes become the next +level's settings as they are (`func_8006D968`, `thread3_main.c:790-806`): +`destinationMapId` the level, `overworldSpawnIndex` the entrance used when that +level is a hub, and `returnSpawnIndex` the entrance of *this* hub the player +comes back to after a race (255: not here). A hub needs a start position for +every entrance something returns to, and entrance 0, which a new game uses. +`LevelObjectEntry_Door.levelID` (`+0x14`) is also a level id, but only for the +door's look: `obj_loop_door` reads that level's cleared and silver-coin flags +to pick the door model. It never warps anywhere. + +### Exits to other custom tracks: `levelLinks` + +A custom track's level id is assigned at load from the installed set, so a hub +cannot store it in `destinationMapId` when it is exported. Track Lab lets an +exit name a destination track by its id instead. The exit is exported with +level 0 (the Central Area hub) as a placeholder, and the manifest says which +byte to patch: + +```json +"levelLinks": [ + { "slot": "structure", "offset": 382, "track": "frosty-ridge" }, + { "slot": "collectables", "offset": 40, "track": "sunset-bay" } +] +``` + +- `slot` is the object map that holds the exit, `structure` or `collectables`. +- `offset` is the position of the exit's `destinationMapId` byte in that map + **as the game holds it after inflating it**. That buffer is the 16-byte map + header (a big-endian `u32` with the size of the entries, then 12 zero bytes), + followed by the entries in order. `track_spawn_objects` starts spawning at + `gObjectMap[index] + sizeof(uintptr_t)` on an `s32 *` (`objects.c:980`), + which is 16 bytes in. So + `offset = 16 + (bytes of the entries before the exit) + 8`, and 8 is + `destinationMapId`'s place in `LevelObjectEntry_Exit`. It always falls in + `[16, 16 + entry bytes)`. +- `track` is the destination's manifest `id`. + +When the map loads, before the exit spawns, the runtime writes the level id it +resolved for `track` into that byte. If that track is not installed, it logs +the link and keeps the placeholder, so the exit leads to the Central Area hub +and not to some other level. Only exits carry links. The source glTF keeps the +destination as `destinationTrack` in the exit's extras, so a re-import keeps +the link. The encoder writes only the entry's own fields, so the extra never +reaches the game's bytes. + ## Progression The retail EEPROM is 512 bytes, bit-packed with per-slot checksums, and fixed @@ -439,6 +486,37 @@ appended level ID. The native menu supports IDs below 128; higher IDs remain available through Track Lab. Hub, boss and special level types are not added to the normal-race grid. +Challenge (battle, bananas, eggs), boss and hub levels play from a `.dkrmap` +through Track Lab's auto boot or a hub exit; retail decides everything from the +header's race type. One retail lookup needs help. The first time a boss level +loads, `level_load` swaps it for the boss's introduction scene, found by +scanning `ASSET_MISC_67` for `{boss level, intro level}` pairs until the level +id matches, and `racer_boss_finish` repeats that scan for the closing scene +unless the course is cleared. An appended level id is in no pair, so both scans +run past the table and load an unrelated level (the camera froze in one). The +`level_load` hook therefore marks a `.dkrmap` boss course visited and cleared +before the header is read: retail's rematch path, which races at once and ends +with a plain transition back to the hub - the one the player came from, or for +a test boot the hub of the world the game is served (Dino Domain for +`WORLD_CUSTOM_TRACKS`). A boss level never runs a silver coin race, so the +cleared bit unlocks nothing; the log shows +`boss race runs without the retail intro and outro scenes`. + +**Shadows on busy floors.** Every racer's shadow is built from the floor +triangles under it (`shadow_generate`, `func_8002E904` in `tracks.c`) into +fixed arrays with no bound checks: 32 vertices (`D_8011B120`), 32 polygons +(`D_8011C238`), and the points clipping creates, kept in four groups of 32 +(`D_8011B330`) - which retail declares with 120 entries, so the fourth group's +last eight land on the polygon count. Retail floors never come close. A custom +floor cut into many small faces can: a plane flying under the covered plaza of +an Eggs arena overflowed them, drew stray triangles and then wrote outside +RDRAM. Two hooks in `func_8002E904` keep each polygon inside the arrays - one +gives the clip no vertices when the fourth group or the polygon slots are +full, one drops a clipped polygon whose vertices might not fit - so the shadow +loses a piece instead. The log says so once per session +(`a shadow crosses more floor edges…` / `a shadow covers more floor +polygons…`). + World `6` only files the course; the game never sees it. Retail indexes five-world arrays with `header->world - 1`. The post-race mosaic read past `gTracksMenuBgTextureIndices`, and `bgdraw_texture` then tiled a non-texture @@ -797,6 +875,25 @@ python scripts/generate_legacy_menu_qualification.py --characters --character-me Point `DKR_GENERATED_SOURCE_V77` / `_V80` at `generated-v77` / `generated-v80` in that folder. The presentation step accepts only the reviewed ELF images. +**A vehicle set holds up to 127 checkpoints.** `checkpoint_update_all` loads +one set's gates (those whose `vehicleType` matches the header's `unk4F` entry +for the vehicle), main and alternate route together, into `gTrackCheckpoints` +in spawn order, and silently skips any past `MAX_CHECKPOINTS` (`objects.c`). +Retail's is 60, which caps how finely a course can steer its AI racing line. +Two `instructionPatches` in each versioned recomp policy raise it to 127: the +table's allocation in `allocate_object_pools` (`addiu a0, zero, 0xE10` becomes +`0x1DC4`, 127 × the 0x3C-byte `CheckpointNode`) and the load bound in +`checkpoint_update_all` (`slti at, a3, 60` becomes `127`). 127 is the real +ceiling: a gate's place in the table lives in signed bytes +(`Object_Racer.nextCheckpoint` at 0x192, `Object_Weapon.checkpoint`, +`CheckpointNode.altRouteID`), and the racer code increments `nextCheckpoint` +before comparing it with the count, so the count may not exceed 127. Nothing +else in the game or the runtime sizes anything by checkpoint count: the AI and +homing-rocket splines take four or five consecutive gates and wrap their index, +`courseCheckpoint` is an `s16`, and the netplay state hash hashes racer fields, +not the table. Track Lab reports a set over 127 as an error and one over 60 as +a warning, since older DKR-R builds and original hardware still load only 60. + **`mods/` belongs to librecomp.** N64ModernRuntime ships its own mod system, it is live in DKR-R, and it scans `mods/` for its `.nrm` format. A `.dkrmap` placed there is reported as `Mod is missing a mod.json` to the user. Custom diff --git a/runtime-recomp/dkr.us.v77.recomp-policy.json b/runtime-recomp/dkr.us.v77.recomp-policy.json index 85d270f1..5cc72140 100644 --- a/runtime-recomp/dkr.us.v77.recomp-policy.json +++ b/runtime-recomp/dkr.us.v77.recomp-policy.json @@ -998,6 +998,24 @@ "text": "extern void dkr_custom_tracks_auto_boot(uint8_t*, recomp_context*); dkr_custom_tracks_auto_boot(rdram, ctx);", "reason": "Immediately after mode_menu reads menu_loop result, before the MENU_RESULT_FLAGS_200 test. Track Lab auto boot answers with the same value the menus produce when a track is chosen, so retail performs its own start sequence rather than the host reproducing vehicle, entrance, cutscene and game mode by hand." }, + { + "function": "track_spawn_objects", + "beforeVram": "0x8000CAC8", + "text": "extern void dkr_custom_tracks_object_map_links(uint8_t*, recomp_context*); dkr_custom_tracks_object_map_links(rdram, ctx);", + "reason": "Right after gzip_inflate returns in track_spawn_objects (the jal at 0x8000CAC0 and its nop delay slot), before any entry spawns. s0 still holds the object map's asset offset and 0x48(sp) the inflated buffer. A Track Lab hub whose EXIT leads to another .dkrmap track carries a placeholder destinationMapId plus manifest levelLinks; the runtime writes the level id resolved for the installed set into that byte. Retail maps and tracks without links are untouched." + }, + { + "function": "func_8002E904", + "beforeVram": "0x8002ED04", + "text": "extern void dkr_shadow_clip_budget(uint8_t*, recomp_context*); dkr_shadow_clip_budget(rdram, ctx);", + "reason": "On the jal to func_8002FF6C, with a0 = 3 already set. The clip appends edge points to D_8011B330 in groups of 32 (retail clamps each at 31), but the array holds 120 entries, so the fourth group's entries 24-31 overwrite D_8011C230 and the polygon array. A custom floor with many small faces under a shadow reached that. When the fourth group cannot take two more points or no polygon slot is left, a0 is set to 0 so the clip adds nothing and returns 0. Retail floors never reach the limit." + }, + { + "function": "func_8002E904", + "beforeVram": "0x8002ED0C", + "text": "extern void dkr_shadow_polygon_budget(uint8_t*, recomp_context*); dkr_shadow_polygon_budget(rdram, ctx);", + "reason": "After func_8002FF6C returns the clipped shadow polygon's vertex count in v0 and before the someCount >= 3 test. func_8002E904 appends polygons to D_8011C238[32] and vertices to D_8011B120[32] without a bound; a custom floor cut into many small faces overflowed them (into the polygon count, then a write outside RDRAM). v0 is zeroed when the polygon may not fit, so the shadow loses that piece instead. Retail floors never reach the limit." + }, { "function": "track_setup_racers", "beforeVram": "0x8000cc7c", @@ -1167,6 +1185,18 @@ "vram": "0x8006F4F4", "value": "0x00000000", "reason": "Use a safe no-op return delay slot for the virtualized CIC probe." + }, + { + "function": "allocate_object_pools", + "vram": "0x8000C008", + "value": "0x24041DC4", + "reason": "Size gTrackCheckpoints for 127 CheckpointNode entries (0x1DC4 = 127 * 0x3C bytes, originally addiu a0, zero, 0xE10 for MAX_CHECKPOINTS 60) so custom tracks can carry finer AI racing lines." + }, + { + "function": "checkpoint_update_all", + "vram": "0x80017F54", + "value": "0x28E1007F", + "reason": "Raise the checkpoint load bound to the patched 127-entry gTrackCheckpoints table (originally slti at, a3, 60); 127 keeps every index and nextCheckpoint + 1 inside the s8 Object_Racer.nextCheckpoint, Object_Weapon.checkpoint and CheckpointNode.altRouteID fields." } ], "ignored": [ diff --git a/runtime-recomp/dkr.us.v80.recomp-policy.json b/runtime-recomp/dkr.us.v80.recomp-policy.json index bf432cbf..49d0d4b8 100644 --- a/runtime-recomp/dkr.us.v80.recomp-policy.json +++ b/runtime-recomp/dkr.us.v80.recomp-policy.json @@ -998,6 +998,24 @@ "text": "extern void dkr_custom_tracks_auto_boot(uint8_t*, recomp_context*); dkr_custom_tracks_auto_boot(rdram, ctx);", "reason": "Immediately after mode_menu reads menu_loop result, before the MENU_RESULT_FLAGS_200 test. Track Lab auto boot answers with the same value the menus produce when a track is chosen, so retail performs its own start sequence rather than the host reproducing vehicle, entrance, cutscene and game mode by hand." }, + { + "function": "track_spawn_objects", + "beforeVram": "0x8000CAC8", + "text": "extern void dkr_custom_tracks_object_map_links(uint8_t*, recomp_context*); dkr_custom_tracks_object_map_links(rdram, ctx);", + "reason": "Right after gzip_inflate returns in track_spawn_objects (the jal at 0x8000CAC0 and its nop delay slot), before any entry spawns. s0 still holds the object map's asset offset and 0x48(sp) the inflated buffer. A Track Lab hub whose EXIT leads to another .dkrmap track carries a placeholder destinationMapId plus manifest levelLinks; the runtime writes the level id resolved for the installed set into that byte. Retail maps and tracks without links are untouched." + }, + { + "function": "func_8002E904", + "beforeVram": "0x8002ED44", + "text": "extern void dkr_shadow_clip_budget(uint8_t*, recomp_context*); dkr_shadow_clip_budget(rdram, ctx);", + "reason": "On the jal to func_8002FF6C, with a0 = 3 already set. The clip appends edge points to D_8011B330 in groups of 32 (retail clamps each at 31), but the array holds 120 entries, so the fourth group's entries 24-31 overwrite D_8011C230 and the polygon array. A custom floor with many small faces under a shadow reached that. When the fourth group cannot take two more points or no polygon slot is left, a0 is set to 0 so the clip adds nothing and returns 0. Retail floors never reach the limit." + }, + { + "function": "func_8002E904", + "beforeVram": "0x8002ED4C", + "text": "extern void dkr_shadow_polygon_budget(uint8_t*, recomp_context*); dkr_shadow_polygon_budget(rdram, ctx);", + "reason": "After func_8002FF6C returns the clipped shadow polygon's vertex count in v0 and before the someCount >= 3 test. func_8002E904 appends polygons to D_8011C238[32] and vertices to D_8011B120[32] without a bound; a custom floor cut into many small faces overflowed them (into the polygon count, then a write outside RDRAM). v0 is zeroed when the polygon may not fit, so the shadow loses that piece instead. Retail floors never reach the limit." + }, { "function": "asset_table_load", "beforeVram": "0x80076F0C", @@ -1335,6 +1353,18 @@ "vram": "0x800773BC", "value": "0x00000000", "reason": "Replace the raw-ROM-copy mutex send call with the contention-safe native hook at the same instruction." + }, + { + "function": "allocate_object_pools", + "vram": "0x8000C008", + "value": "0x24041DC4", + "reason": "Size gTrackCheckpoints for 127 CheckpointNode entries (0x1DC4 = 127 * 0x3C bytes, originally addiu a0, zero, 0xE10 for MAX_CHECKPOINTS 60) so custom tracks can carry finer AI racing lines." + }, + { + "function": "checkpoint_update_all", + "vram": "0x80017F88", + "value": "0x28E1007F", + "reason": "Raise the checkpoint load bound to the patched 127-entry gTrackCheckpoints table (originally slti at, a3, 60); 127 keeps every index and nextCheckpoint + 1 inside the s8 Object_Racer.nextCheckpoint, Object_Weapon.checkpoint and CheckpointNode.altRouteID fields." } ], "ignored": [ diff --git a/runtime-recomp/src/game/custom_tracks.cpp b/runtime-recomp/src/game/custom_tracks.cpp index 43d1a9ae..b170bb2c 100644 --- a/runtime-recomp/src/game/custom_tracks.cpp +++ b/runtime-recomp/src/game/custom_tracks.cpp @@ -35,6 +35,7 @@ using dkr::runtime::custom_tracks::Entry; using dkr::runtime::custom_tracks::MapSlot; using dkr::runtime::custom_tracks::Section; using dkr::runtime::custom_tracks::Track; +using dkr::runtime::custom_tracks::LevelLink; constexpr std::size_t kSectionCount = 7U; @@ -853,6 +854,57 @@ std::int32_t level_model_batches(std::int32_t level_id) { return -1; } +std::vector level_links_for_object_map(std::uint32_t offset) { + std::vector found; + std::scoped_lock lock(g_mutex); + const SectionState& maps = g_sections[section_slot(Section::LevelObjectMaps)]; + if (!maps.built) { + return found; + } + for (const AddedEntry& entry : maps.added) { + if (entry.offset != offset || entry.track_id.empty()) { + continue; + } + for (const Track& track : g_tracks) { + if (track.id != entry.track_id || !track.enabled) { + continue; + } + for (const LevelLink& link : track.level_links) { + if (link.slot != entry.slot) { + continue; + } + const auto resolved = g_resolved_level_ids.find(link.track); + found.push_back({link.offset, + resolved != g_resolved_level_ids.end() ? resolved->second : -1, + link.track}); + } + } + break; + } + return found; +} + +std::int32_t level_race_type(std::int32_t level_id) { + constexpr std::size_t kRaceTypeOffset = 0x4CU; + std::scoped_lock lock(g_mutex); + for (const auto& [track_id, resolved] : g_resolved_level_ids) { + if (resolved != level_id) { + continue; + } + for (const Track& track : g_tracks) { + if (track.id != track_id || !track.enabled) { + continue; + } + for (const Entry& entry : track.entries) { + if (entry.section == Section::LevelHeaders && entry.bytes.size() > kRaceTypeOffset) { + return entry.bytes[kRaceTypeOffset]; + } + } + } + } + return -1; +} + std::int32_t measure_level_model_arena(const std::uint8_t* bytes, std::size_t size) { // LevelModel and LevelModelSegment offsets, docs/LEVEL_MODEL_FORMAT.md. constexpr std::size_t kContainerHeader = 5U; @@ -1932,6 +1984,49 @@ bool parse_track(const std::filesystem::path& input_root, Track& track, return false; } + // A hub's exits may lead to other .dkrmap tracks. Their level ids exist only + // once the installed set is known, so the package names the track and the + // byte to fill in; see level_links_for_object_map(). + if (manifest.contains("levelLinks")) { + const auto& links = manifest["levelLinks"]; + if (!links.is_array() || links.size() > 255) { + error = "levelLinks must be an array of at most 255 links"; + return false; + } + constexpr std::uint32_t kMaxObjectMapBytes = 0x10000U; + for (const auto& link : links) { + if (!link.is_object() || !link.contains("slot") || !link["slot"].is_string() || + !link.contains("offset") || !link["offset"].is_number_unsigned() || + !link.contains("track") || !link["track"].is_string()) { + error = "levelLinks entries need slot, offset and track"; + return false; + } + LevelLink parsed; + const auto slot = link["slot"].get(); + if (slot == "structure") { + parsed.slot = MapSlot::Structure; + } else if (slot == "collectables") { + parsed.slot = MapSlot::Collectables; + } else { + error = "levelLinks slot must be structure or collectables"; + return false; + } + const auto offset = link["offset"].get(); + // Past the 16-byte map header, inside the game's object map buffer. + if (offset < 0x10U || offset >= kMaxObjectMapBytes) { + error = "levelLinks offset " + std::to_string(offset) + " is outside an object map"; + return false; + } + parsed.offset = static_cast(offset); + parsed.track = link["track"].get(); + if (parsed.track.empty()) { + error = "levelLinks track is empty"; + return false; + } + track.level_links.push_back(std::move(parsed)); + } + } + // A header carries 0 in both object map fields, because the real indices // are assigned at load. That is only safe when both are patched: 0 is a // valid index, not "no map", so an unpatched field makes the game spawn diff --git a/runtime-recomp/src/game/custom_tracks.hpp b/runtime-recomp/src/game/custom_tracks.hpp index f89ddf8b..f578a5bd 100644 --- a/runtime-recomp/src/game/custom_tracks.hpp +++ b/runtime-recomp/src/game/custom_tracks.hpp @@ -282,6 +282,19 @@ struct MusicInfo { std::uint16_t channel_mask = 0xFFFF; // header /instruments }; +// manifest.levelLinks: a byte of one of the track's own object maps that names +// another .dkrmap track's level - a hub EXIT's destinationMapId. Level ids are +// assigned from the installed set at runtime, so the author cannot write the +// number; the exporter leaves a placeholder and records which track it means. +// `offset` counts from the start of the inflated object map, its 16-byte header +// (u32 entry bytes, then padding) included: the buffer track_spawn_objects +// spawns from, whose entries begin at +0x10. +struct LevelLink { + MapSlot slot = MapSlot::Structure; + std::uint32_t offset = 0; + std::string track; +}; + struct Track { std::string id; std::string name; @@ -304,6 +317,7 @@ struct Track { bool hd_pack_sibling_mismatch = false; // sibling present, digest differs std::optional music; // manifest.music (schema 2) std::optional water; // Renderer-only appearance. + std::vector level_links; // manifest.levelLinks }; // Scans `directory` for *.dkrmap archives and parses their manifests. Invalid @@ -421,6 +435,11 @@ struct PreparedTracks { // level loads. -1 when the level is not a .dkrmap track, ships no model of its // own, or the model cannot be read. [[nodiscard]] std::int32_t level_model_batches(std::int32_t level_id); +// The race type (LevelHeader byte 0x4C) of the .dkrmap track at `level_id`, or +// -1 when the level is not one of them. +[[nodiscard]] std::int32_t level_race_type(std::int32_t level_id); +// LevelHeader race types the runtime treats specially. +inline constexpr std::int32_t kRaceTypeBoss = 8; // The same count for one compressed LEVEL_MODELS payload. [[nodiscard]] std::int32_t count_level_model_batches(const std::uint8_t* bytes, std::size_t size); @@ -557,6 +576,16 @@ void set_auto_boot(bool enabled); [[nodiscard]] std::int32_t sibling_index(Section from, std::uint32_t offset, Section to, MapSlot slot); +// The level links of the custom object map served from `offset`, each with the +// level id its track resolved to, or -1 when that track is not installed or +// enabled. Empty for a retail map or a track with no links. +struct ResolvedLevelLink { + std::uint32_t offset; + std::int32_t level_id; + std::string track; +}; +[[nodiscard]] std::vector level_links_for_object_map(std::uint32_t offset); + // The id range the exporter writes into a level model's texture table for the // track's own artwork, standing in for indices only the runtime can assign. // diff --git a/runtime-recomp/src/game/custom_tracks_hooks.cpp b/runtime-recomp/src/game/custom_tracks_hooks.cpp index 1a1e91e5..eea3a224 100644 --- a/runtime-recomp/src/game/custom_tracks_hooks.cpp +++ b/runtime-recomp/src/game/custom_tracks_hooks.cpp @@ -757,6 +757,51 @@ void dkr::runtime::custom_tracks::set_level_load_observer(LevelLoadObserver obse g_level_load_observer = observer; } +// A boss race that has not been visited is not loaded at all the first time: +// level_load (game.c, the RACETYPE_BOSS branch) pushes it and loads the boss's +// introduction scene instead, found by scanning ASSET_MISC_67 for pairs of +// {boss level, intro level} until the level id matches. A .dkrmap level is in +// no such pair, so the scan runs past the table and loads whatever level id it +// meets - the camera then sits frozen in an unrelated scene. racer_boss_finish +// (vehicle_tricky.c) repeats that scan with settings->courseId when the player +// finishes, unless the course is already cleared, and pushes the level it found +// as the next scene. So the course is marked visited and cleared (bits 0 and 1 +// of its courseFlags): retail's own rematch path, which starts the race +// directly and ends it with a plain transition. world 6 is served as Dino +// Domain, so the Wizpig worlds' unconditional intro never applies to a .dkrmap +// header, and a boss level never runs a silver coin race (objects.c tests +// level_type()), so the cleared bit unlocks nothing. +static void skip_custom_boss_intro(std::uint8_t* rdram, recomp_context* context, + std::int32_t level) { + namespace tracks_ns = dkr::runtime::custom_tracks; + if (level < 0 || tracks_ns::level_race_type(level) != tracks_ns::kRaceTypeBoss) { + return; + } + const auto* payload = dkr::runtime::active_payload(); + if (!payload || !payload->get_settings) { + return; + } + recomp_context call = *context; + payload->get_settings(rdram, &call); + const auto settings = static_cast(call.r2); + constexpr std::uint32_t kCourseFlagsPointer = 4U; // Settings::courseFlagsPtr + constexpr std::int32_t kRaceVisitedAndCleared = 1 | 2; + if (!addressable(settings + kCourseFlagsPointer)) { + return; + } + const auto flags = static_cast(read_word(rdram, settings + kCourseFlagsPointer)); + const std::uint32_t at = flags + 4U * static_cast(level); + if (!addressable(flags) || !addressable(at)) { + return; + } + const std::int32_t value = read_word(rdram, at); + if ((value & kRaceVisitedAndCleared) != kRaceVisitedAndCleared) { + write_word(rdram, at, value | kRaceVisitedAndCleared); + std::fprintf(stderr, "[custom-tracks] level %d: boss race runs without the retail intro and outro scenes\n", + static_cast(level)); + } +} + // Called by the existing level_load scene-reset hook, before the level // allocates anything. Every load passes here - races, Track Lab and restarts, // and also the Track Select previews that load_level_game's hook never sees - @@ -773,6 +818,7 @@ extern "C" void dkr_custom_tracks_prepare_memory(std::uint8_t* rdram, const auto level = static_cast(context->r4); grow_main_pool_for_level(rdram, level); prepare_track_heap(level); + skip_custom_boss_intro(rdram, context, level); if (g_level_load_observer != nullptr) { g_level_load_observer(level); } @@ -908,3 +954,114 @@ extern "C" void dkr_custom_tracks_prepare_vehicle(std::uint8_t* rdram, std::fprintf(stderr, "[track-lab] level=%d vehicle=%d selected=%d players=%d\n", level, vehicle, selected, players_minus_one + 1); } + +// In track_spawn_objects, on the instruction after gzip_inflate returns +// (0x8000CAC8 in US 1.0 and Rev A alike) and before any entry is spawned: +// s0 still holds the object map's asset offset, the one asset_load was given, +// and 0x48(sp) the buffer the map was inflated into - a 16-byte header that +// starts with the entries' byte count, then the entries. A hub EXIT that leads to another .dkrmap track was exported with +// a placeholder destination, since that track's level id depends on what is +// installed; the package's levelLinks name the byte and the track, and the id +// resolved for this session is written in before the exit spawns. A link whose +// track is missing keeps the placeholder (the exporter writes a retail hub). +extern "C" void dkr_custom_tracks_object_map_links(std::uint8_t* rdram, + recomp_context* context) { + if (dkr::runtime::legacy::frozen_online_resources()) return; + if (owned_vanilla_bootstrap()) return; + const auto offset = static_cast(context->r16); + const auto links = dkr::runtime::custom_tracks::level_links_for_object_map(offset); + if (links.empty()) { + return; + } + const auto stack = static_cast(context->r29); + if (!addressable(stack + 0x48U)) { + return; + } + const auto buffer = static_cast(read_word(rdram, stack + 0x48U)); + if (!addressable(buffer)) { + return; + } + const std::int32_t entry_bytes = read_word(rdram, buffer); + for (const auto& link : links) { + if (entry_bytes <= 0 || link.offset < 0x10U || + link.offset >= 0x10U + static_cast(entry_bytes) || + !addressable(buffer + link.offset)) { + std::fprintf(stderr, "[custom-tracks] level link to %s at byte %u is outside the object map\n", + link.track.c_str(), link.offset); + continue; + } + if (link.level_id < 0 || link.level_id > 0xFF) { + std::fprintf(stderr, "[custom-tracks] level link to %s: track not installed, exit keeps its placeholder\n", + link.track.c_str()); + continue; + } + MEM_B(static_cast(link.offset), rdram_address(buffer)) = + static_cast(link.level_id); + std::fprintf(stderr, "[custom-tracks] level link to %s now leads to level %d\n", + link.track.c_str(), static_cast(link.level_id)); + } +} + +// In func_8002E904 (tracks.c, the per-segment pass of shadow_generate), on the +// instruction after func_8002FF6C returns: v0 is the vertex count of the +// shadow polygon just clipped from one floor triangle. The pass then appends +// that polygon to D_8011C238[32] and its new vertices to D_8011B120[32] with +// no bound check. Retail floors never come near either limit, but a custom +// floor cut into many small faces can: under a .dkrmap's covered plaza a +// plane's shadow gathered more than 32 vertices, ran over the arrays behind +// them into the polygon count, and the next store through that count wrote far +// outside RDRAM (stray triangles on screen, then an access violation). When +// the clipped polygon might not fit - the vertex count plus every one of its +// vertices being new, or no polygon slot left - v0 is set to 0, so the retail +// `someCount >= 3` test skips it and the shadow simply misses that piece. +extern "C" void dkr_shadow_polygon_budget(std::uint8_t* rdram, recomp_context* context) { + namespace addresses = dkr::runtime::revision_addresses; + constexpr std::int32_t kSlots = 32; // D_8011B120[32] and D_8011C238[32] + const auto clipped = static_cast(context->r2); + if (clipped < 3) { + return; + } + const std::int32_t vertices = read_word(rdram, addresses::ShadowVertexCount); + const std::int32_t polygons = read_word(rdram, addresses::ShadowPolygonCount); + if (polygons >= 0 && polygons < kSlots && vertices >= 0 && vertices + clipped <= kSlots) { + return; + } + context->r2 = 0; + static bool reported = false; + if (!reported) { + reported = true; + std::fprintf(stderr, + "[custom-tracks] a shadow covers more floor polygons than the game's 32-entry " + "shadow arrays hold; the rest of that shadow is dropped\n"); + } +} + +// In func_8002E904, on the jal to func_8002FF6C (a0 = 3, the triangle's vertex +// count, is already set). Clipping against the shadow's four edges appends the +// points it creates to D_8011B330, in groups of 32 per edge counted by +// D_8011B320[4] - and retail clamps each group at index 31. But D_8011B330 +// holds 120 entries, not 128, so the fourth group fits only 24: its entries 24 +// to 31 land on D_8011C230 (the polygon count) and the polygon array behind it. +// One clip adds at most two points to each group. When the fourth group has no +// room for two more, or no polygon slot is left, a0 is set to 0: the clip then +// processes no vertices, adds nothing and returns 0, and the polygon is skipped. +extern "C" void dkr_shadow_clip_budget(std::uint8_t* rdram, recomp_context* context) { + namespace addresses = dkr::runtime::revision_addresses; + constexpr std::int32_t kPolygonSlots = 32; // D_8011C238[32] + constexpr std::int32_t kLastGroupSlots = 24; // D_8011B330[120] - 3 * 32 + constexpr std::int32_t kPointsPerClip = 2; // a convex polygon crosses an edge at most twice + const std::int32_t polygons = read_word(rdram, addresses::ShadowPolygonCount); + const std::int32_t last_group = read_word(rdram, addresses::ShadowEdgePointCounts + 12U); + if (polygons >= 0 && polygons < kPolygonSlots && last_group >= 0 && + last_group + kPointsPerClip <= kLastGroupSlots) { + return; + } + context->r4 = 0; + static bool reported = false; + if (!reported) { + reported = true; + std::fprintf(stderr, + "[custom-tracks] a shadow crosses more floor edges than the game's shadow " + "clip arrays hold; the rest of that shadow is dropped\n"); + } +} diff --git a/runtime-recomp/src/game/revision_addresses.hpp b/runtime-recomp/src/game/revision_addresses.hpp index 59ab3687..a07ff7f5 100644 --- a/runtime-recomp/src/game/revision_addresses.hpp +++ b/runtime-recomp/src/game/revision_addresses.hpp @@ -113,6 +113,9 @@ struct AddressTable { std::uint32_t SequenceTable; // gSequenceTable (ALSeqFile *) std::uint32_t SequenceLengths; // gSeqLengthTable (u32 *) std::uint32_t SequenceSoundTable; // gSeqSoundTable (MusicData *) + std::uint32_t ShadowVertexCount; // D_8011B118 (tracks.c): entries in the 32-slot shadow vertex array + std::uint32_t ShadowPolygonCount; // D_8011C230 (tracks.c): entries in the 32-slot shadow polygon array + std::uint32_t ShadowEdgePointCounts; // D_8011B320[4] (tracks.c): clip points per shadow edge, groups of 32 in D_8011B330[120] }; inline constexpr AddressTable kUsV77{ @@ -218,6 +221,9 @@ inline constexpr AddressTable kUsV77{ 0x80115CF8U, 0x80115D0CU, 0x80115D1CU, + 0x8011B118U, // D_8011B118, verified v77 symbol table + 0x8011C230U, // D_8011C230 + 0x8011B320U, // D_8011B320 }; inline constexpr AddressTable kUsV80{ @@ -323,6 +329,9 @@ inline constexpr AddressTable kUsV80{ 0x80116278U, 0x8011628CU, 0x8011629CU, + 0x8011B698U, // D_8011B118, verified v80 symbol table + 0x8011C7B0U, // D_8011C230 + 0x8011B8A0U, // D_8011B320 }; inline rom::Revision gSelectedRevision = rom::Revision::UsV77; @@ -428,6 +437,9 @@ inline std::uint32_t CurrentSequence = kUsV77.CurrentSequence; inline std::uint32_t SequenceTable = kUsV77.SequenceTable; inline std::uint32_t SequenceLengths = kUsV77.SequenceLengths; inline std::uint32_t SequenceSoundTable = kUsV77.SequenceSoundTable; +inline std::uint32_t ShadowVertexCount = kUsV77.ShadowVertexCount; +inline std::uint32_t ShadowPolygonCount = kUsV77.ShadowPolygonCount; +inline std::uint32_t ShadowEdgePointCounts = kUsV77.ShadowEdgePointCounts; inline const AddressTable& table_for(const rom::Revision revision) { return revision == rom::Revision::UsV80 ? kUsV80 : kUsV77; @@ -541,6 +553,9 @@ inline bool select(const rom::Revision revision) { SequenceTable = table.SequenceTable; SequenceLengths = table.SequenceLengths; SequenceSoundTable = table.SequenceSoundTable; + ShadowVertexCount = table.ShadowVertexCount; + ShadowPolygonCount = table.ShadowPolygonCount; + ShadowEdgePointCounts = table.ShadowEdgePointCounts; gSelectedRevision = revision; return true; } diff --git a/runtime-recomp/src/game/runtime_code_mods.cpp b/runtime-recomp/src/game/runtime_code_mods.cpp index d2d8a808..4825a8eb 100644 --- a/runtime-recomp/src/game/runtime_code_mods.cpp +++ b/runtime-recomp/src/game/runtime_code_mods.cpp @@ -114,3 +114,17 @@ extern "C" std::uint32_t dkr_code_lap_address(std::uint32_t address,std::uint8_t if(bound->world->lap_address(address,result)<0)stop(bound->world->error()); return std::uint32_t(result); } + +// The composed payload always calls dkr_analogue_driving (legacy_behaviour_policy), +// but its definition still arrives with the release overlay +// (patches/online-performance). A plain runtime-recomp configure therefore did not +// link. When nothing else defines it, the linker falls back to this pass-through, +// which leaves the retail throttle and brake values untouched; the overlay's real +// definition wins whenever it is present. +extern "C" std::uint32_t dkr_analogue_driving_retail(std::uint8_t*, recomp_context*, unsigned, + std::uint32_t, std::uint32_t bits) { + return bits; +} +#if defined(_MSC_VER) +#pragma comment(linker, "/alternatename:dkr_analogue_driving=dkr_analogue_driving_retail") +#endif diff --git a/runtime-recomp/tests/custom_tracks_tests.cpp b/runtime-recomp/tests/custom_tracks_tests.cpp index b2a126d3..2932d925 100644 --- a/runtime-recomp/tests/custom_tracks_tests.cpp +++ b/runtime-recomp/tests/custom_tracks_tests.cpp @@ -298,8 +298,69 @@ int level_count(const std::vector& table) { } // namespace +// A hub's exits name other .dkrmap tracks through levelLinks, resolved +// against the installed set; a missing track resolves to -1, and the race +// type a boss track declares is read back from its header. +void level_link_tests(const std::filesystem::path& base) { + { + const std::filesystem::path links = base / "level-links"; + std::filesystem::remove_all(links); + set_working_directory({}); + const std::filesystem::path hub = links / "hub.dkrmap"; + std::filesystem::create_directories(hub); + write_file(hub / "manifest.json", + "{\"schemaVersion\":1,\"id\":\"hub\",\"name\":\"Hub\"," + "\"adds\":[{\"section\":\"LEVEL_HEADERS\",\"file\":\"h.bin\"}," + "{\"section\":\"LEVEL_OBJECT_MAPS\",\"slot\":\"structure\",\"file\":\"s.bin\"}," + "{\"section\":\"LEVEL_OBJECT_MAPS\",\"slot\":\"collectables\",\"file\":\"o.bin\"}]," + "\"levelLinks\":[{\"slot\":\"structure\",\"offset\":24,\"track\":\"race\"}," + "{\"slot\":\"structure\",\"offset\":40,\"track\":\"gone\"}]}"); + std::string boss_header(200U, '\0'); + boss_header[0x4C] = static_cast(kRaceTypeBoss); + write_file(hub / "h.bin", boss_header); + write_file(hub / "s.bin", std::string(48U, 's')); + write_file(hub / "o.bin", std::string(64U, 'o')); + write_track(links / "race.dkrmap", "race", 128U); + const std::filesystem::path bad = links / "bad.dkrmap"; + std::filesystem::create_directories(bad); + write_file(bad / "manifest.json", + "{\"schemaVersion\":1,\"id\":\"bad\",\"name\":\"Bad\"," + "\"adds\":[{\"section\":\"LEVEL_HEADERS\",\"file\":\"h.bin\"}]," + "\"levelLinks\":[{\"slot\":\"structure\",\"offset\":12,\"track\":\"race\"}]}"); + write_file(bad / "h.bin", std::string(200U, 'h')); + scan(links); + assert(tracks().size() == 2U); // The map header is not a link target. + + constexpr std::int32_t kMaps[] = {0x0, 0x80, 0x100, -1}; + const auto headers = build_extended_table(Section::LevelHeaders, kRetail); + const auto maps = build_extended_table(Section::LevelObjectMaps, kMaps); + assert(!headers.empty() && !maps.empty()); + const std::int32_t hub_level = resolved_level_id("hub"); + const std::int32_t race_level = resolved_level_id("race"); + assert(hub_level >= 0 && race_level >= 0 && hub_level != race_level); + assert(level_race_type(hub_level) == kRaceTypeBoss); + assert(level_race_type(race_level) >= 0); // Read from its own header. + assert(level_race_type(0) == -1); + + const std::int32_t structure = sibling_index( + Section::LevelHeaders, static_cast(headers[hub_level]), + Section::LevelObjectMaps, MapSlot::Structure); + const std::int32_t collectables = sibling_index( + Section::LevelHeaders, static_cast(headers[hub_level]), + Section::LevelObjectMaps, MapSlot::Collectables); + assert(structure >= 0 && collectables >= 0); + const auto found = level_links_for_object_map(static_cast(maps[structure])); + assert(found.size() == 2U); + assert(found[0].offset == 24U && found[0].level_id == race_level && found[0].track == "race"); + assert(found[1].offset == 40U && found[1].level_id == -1); + assert(level_links_for_object_map(static_cast(maps[collectables])).empty()); + assert(level_links_for_object_map(0x0).empty()); // Retail map. + } +} + int main() { const TemporaryDirectory temporary("dkrr-custom-tracks-tests"); + level_link_tests(temporary.path()); const std::filesystem::path root = temporary.path() / "fixtures"; std::filesystem::remove_all(root); write_track(root / "beta.dkrmap", "beta", 192U); From 39e45c057e897a9096c6afa11babcd1bbb86ac99 Mon Sep 17 00:00:00 2001 From: leite-matheus <78598648+leite-matheus@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:26:51 -0300 Subject: [PATCH 08/11] Add the Track Lab mode checklist, AI node graph and level links The addon gets a Mode Checklist panel for battle, banana, egg, boss and hub levels, an enabled AI Node Graph panel, mode validation and a "Leads to" destination for hub exits that is exported as manifest levelLinks. Adding an AI branch no longer fails on the wrong class. --- tools/blender/CHANGELOG.md | 45 + tools/blender/generate_catalog.py | 14 + tools/blender/run_tests.py | 6 +- tools/blender/tests/test_ai_graph.py | 246 ++++++ tools/blender/tests/test_blender_ai_graph.py | 393 +++++++++ tools/blender/tests/test_blender_modes.py | 338 ++++++++ tools/blender/tests/test_level_links.py | 236 +++++ tools/blender/tests/test_mode_checklist.py | 375 ++++++++ tools/blender/tests/test_race_ai.py | 62 +- tools/blender/track_lab/__init__.py | 16 +- tools/blender/track_lab/ai_graph.py | 287 ++++++- tools/blender/track_lab/ai_graph_view.py | 288 +++++++ tools/blender/track_lab/data/catalog.json | 5 + tools/blender/track_lab/dkrmap.py | 24 +- tools/blender/track_lab/level_links.py | 104 +++ tools/blender/track_lab/level_types.py | 29 +- tools/blender/track_lab/mode_checklist.py | 699 +++++++++++++++ tools/blender/track_lab/object_map_encoder.py | 22 +- tools/blender/track_lab/operators/ai.py | 804 +++++++++++++++++- tools/blender/track_lab/operators/checks.py | 3 + tools/blender/track_lab/operators/edit.py | 125 ++- .../track_lab/operators/mode_checklist.py | 413 +++++++++ tools/blender/track_lab/operators/pack.py | 5 +- tools/blender/track_lab/operators/race_ai.py | 2 +- tools/blender/track_lab/props.py | 63 ++ tools/blender/track_lab/race_ai.py | 26 +- tools/blender/track_lab/scene.py | 23 +- tools/blender/track_lab/ui/panels.py | 359 +++++++- tools/blender/track_lab/validate.py | 98 ++- 29 files changed, 4986 insertions(+), 124 deletions(-) create mode 100644 tools/blender/tests/test_ai_graph.py create mode 100644 tools/blender/tests/test_blender_ai_graph.py create mode 100644 tools/blender/tests/test_blender_modes.py create mode 100644 tools/blender/tests/test_level_links.py create mode 100644 tools/blender/tests/test_mode_checklist.py create mode 100644 tools/blender/track_lab/ai_graph_view.py create mode 100644 tools/blender/track_lab/level_links.py create mode 100644 tools/blender/track_lab/mode_checklist.py create mode 100644 tools/blender/track_lab/operators/mode_checklist.py diff --git a/tools/blender/CHANGELOG.md b/tools/blender/CHANGELOG.md index 62e4ca55..fa2b76ec 100644 --- a/tools/blender/CHANGELOG.md +++ b/tools/blender/CHANGELOG.md @@ -5,6 +5,51 @@ releases the version `track_lab/blender_manifest.toml` names, with its section from this file. Bump the version in the manifest and in `bl_info` (`track_lab/__init__.py`), and add the section here, in the same commit. +## Unreleased + +- **Up to 127 checkpoints per vehicle set**, up from 60, for finer control of + the AI racing line. The limit is the runtime's: a DKR-R build carrying the + raised checkpoint table loads 127; older DKR-R builds and original hardware + still load only the first 60 placed. Validation reports a set past 127 as an + error and one past 60 as a warning, and the AI Racers panel says the same. +- **Mode Checklist** under Level Type, for Battle, Bananas, Eggs, Hub and Boss + Race. It lists what each mode needs to play: start ring, AI graph, + treasure chests, Coin Creators, egg nests and Head-For Points, exits and + their destinations, the boss's checkpoints. Each row says why it matters + and, where it can, has a button that adds it: chests, flags and Head-For + Points are placed per racer with the racer filled in. Validate reports the + same rows, so a battle arena with no AI graph ("AI racers will not move") or + an egg arena with no Egg Creator now stops the export. +- **AI Node Graph is enabled** for Battle, Bananas and hubs, and for a race + that has a loop-de-loop. New **Tag Destinations** gives the nodes nearest + the weapon balloons and the chests the destination classes the arena AI + looks for. Add Branch no longer resets every node's elevation and + destination class to the branch's. +- **Weapon nodes for arena bots.** Battle and Bananas bots never look at a + balloon: they drive the AI node graph to nodes of destination class 1. With + the new *Auto Weapon Node* (Place, on by default), placing a weapon balloon + there also places a class-1 node on it, parented so it moves with the + balloon, and links it to the two nearest graph nodes within 1,500 units - + or, with no graph yet, AI Line From Curve keeps it and links it once the + graph is drawn. *Link Nodes* / *Unlink Nodes* join or split two selected + nodes both ways, and *Link Weapon Nodes* joins any left loose. +- **Show AI Graph** overlay in AI Node Graph: every link, weapon nodes ringed + in their balloon's colour with a 50-unit reach circle (green with a balloon + inside, red and labelled when not), bank nodes in their racer's colour, + optional node ids. Select a node for a heat map out from it, by steps or by + distance, with unreachable parts in grey, and the dashed route a bot without + a weapon drives from there. DKR Object says what a node is for, lists its + links and selects a weapon node's balloon. +- The Mode Checklist and Validate warn about a weapon node with no balloon + within 100 units or no link to the graph, and Tag Destinations warns when a + balloon is more than 50 units from the node it tagged. +- **Hub exits to other custom tracks.** In DKR Object, *Leads to* picks a + retail level or a custom track by id. The export writes a placeholder and + a `levelLinks` entry in the manifest, and DKR-R fills in the track's level + id when the hub loads (docs/CUSTOM_TRACKS.md, *Hub doors*). +- A few raw fields now have names: an AI node's destination class, a + Head-For Point's kind and racer, and the racer a chest or flag belongs to. + ## 0.1.0 The first release of Track Lab. diff --git a/tools/blender/generate_catalog.py b/tools/blender/generate_catalog.py index 8a220cbb..36df40a1 100644 --- a/tools/blender/generate_catalog.py +++ b/tools/blender/generate_catalog.py @@ -532,6 +532,20 @@ def _hint_step(hint): + [("unk%s" % name, "Route flag, lane %d" % (index + 1)) for index, name in enumerate(("13", "14", "15", "16"))] ), + # The arena AI's pathfinder targets nodes by this byte (func_8001CD28, + # objects.c:7675): 1 is a weapon balloon (racer.c:929, :948), 4 + racer + # the racer's bank in the banana challenge (racer.c:951). + "ASSET_OBJECT_AINODE": {"unk8": "Destination class"}, + # racer_ai_eggs (racer.c:1052) matches a Head-For Point on unk8 as the + # kind of target and unk9 as the racer it belongs to (racer.c:1219-1223): + # 0 is where a racer carrying an egg drops it, 2 its way home. + "ASSET_OBJECT_HEADFORPOINT": {"unk8": "Kind (0 drop, 2 home)", + "unk9": "Racer (0-3)"}, + # obj_init_treasuresucker stores (playerID - 1) & 3 (object_functions.c: + # 4204), so the chest of racer 0 says 1; the flag's index is 0-based + # (object_functions.c:1084). + "ASSET_OBJECT_TREASURESUCKER": {"playerID": "Racer (1-4)"}, + "ASSET_OBJECT_CHARACTERFLAG": {"playerIndex": "Racer (0-3)"}, } diff --git a/tools/blender/run_tests.py b/tools/blender/run_tests.py index e22c6174..65ebb10e 100644 --- a/tools/blender/run_tests.py +++ b/tools/blender/run_tests.py @@ -38,11 +38,13 @@ "test_minimap.py", "test_music.py", "test_music_sequence.py", "test_midi_import.py", "test_checkpoint_order.py", "test_rom_extract.py", "test_version.py", "test_model_transaction.py", "test_render_water.py", - "test_flythrough.py", + "test_flythrough.py", "test_mode_checklist.py", "test_level_links.py", + "test_ai_graph.py", ] BLENDER_SUITES = ["test_blender_roundtrip.py", "test_blender_operators.py", "test_blender_waterfalls.py", "test_blender_minimap.py", - "test_blender_music.py", "test_blender_rom_assets.py"] + "test_blender_music.py", "test_blender_rom_assets.py", + "test_blender_modes.py", "test_blender_ai_graph.py"] def find_blender(explicit=None): diff --git a/tools/blender/tests/test_ai_graph.py b/tools/blender/tests/test_ai_graph.py new file mode 100644 index 00000000..c3d864ff --- /dev/null +++ b/tools/blender/tests/test_ai_graph.py @@ -0,0 +1,246 @@ +"""Check the AI node graph helpers the arena tools and the overlay stand on. + +What a bot does with the graph (``racer.c:682``, ``objects.c:7675``): it walks +links, and a bot without a weapon heads for the destination-class-1 node +nearest by path, never the one it stands on. The helpers that model that, the +slot arithmetic behind Link and Unlink, the balloon-reach checks, and the +overlay's geometry, all without Blender. + + python tools/blender/tests/test_ai_graph.py +""" + +from __future__ import annotations + +import math +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) + +from track_lab import ai_graph as ag, ai_graph_view as view # noqa: E402 + +FAILURES = [] +E = ag.NO_NEIGHBOUR + + +def check(condition, message): + if condition: + print(" ok %s" % message) + else: + print(" FAIL %s" % message) + FAILURES.append(message) + + +def chain(count, spacing=100.0): + """Nodes 0..count-1 in a line along X, each linked to the next.""" + nodes = [] + for i in range(count): + adjacent = [n for n in (i - 1, i + 1) if 0 <= n < count] + nodes.append((i, adjacent + [E] * (4 - len(adjacent)))) + positions = {i: (i * spacing, 0.0, 0.0) for i in range(count)} + return nodes, positions + + +# --------------------------------------------------------------------------- + +def test_adjacency(): + print("reading adjacency the way the scene may hold it") + found = ag.adjacency([(0, [1, 255, 7, 1]), (1, [0, 1, 255, 255]), + (2, [255, 255, 255, 255]), (0, [2, 255, 255, 255])]) + check(found == {0: [1], 1: [0], 2: []}, + "empty slots, missing ids, self links, repeats and a second node 0 " + "are dropped (%r)" % found) + check(ag.adjacency([("x", [1]), (3, None)]) == {3: []}, + "a node with no id or no slots does not break it") + check(ag.links({0: [1, 2], 1: [0], 2: []}) == [(0, 1), (0, 2)], + "each link is listed once, one-way ones included") + + +def test_heat(): + print("how far each node is from the selected one") + nodes, positions = chain(5) + adjacent = ag.adjacency(nodes) + check(ag.steps_from(adjacent, 2) == {2: 0, 1: 1, 3: 1, 0: 2, 4: 2}, + "steps go out both ways along a chain") + check(ag.steps_from(adjacent, 9) == {}, "an unknown start reaches nothing") + + # A square with a long side: 0-1-2 is shorter than 0-3-2 by distance, + # though both are two steps. + positions = {0: (0, 0, 0), 1: (100, 0, 0), 2: (100, 100, 0), 3: (-400, 300, 0)} + adjacent = {0: [1, 3], 1: [0, 2], 2: [1, 3], 3: [0, 2]} + distance, previous = ag.path_distances(adjacent, positions, 0) + check(abs(distance[2] - 200.0) < 1e-6 and previous[2] == 1, + "Dijkstra takes the short way round (%r)" % distance) + check(abs(distance[3] - 500.0) < 1e-6, "and measures each link's length") + + split = {0: [1], 1: [0], 2: [3], 3: [2]} + check(set(ag.steps_from(split, 0)) == {0, 1}, + "a disconnected piece is not reached") + + +def test_bot_route(): + print("the route a bot without a weapon drives") + nodes, positions = chain(6) + adjacent = ag.adjacency(nodes) + classes = {0: 1, 5: 1} + check(ag.bot_route(adjacent, positions, classes, 2) == [2, 1, 0], + "to the nearest weapon node by path") + check(ag.bot_route(adjacent, positions, classes, 0) == [0, 1, 2, 3, 4, 5], + "standing on one, it goes on to the next: never its own node") + check(ag.bot_route(adjacent, positions, {}, 2) == [], + "no weapon node, no route") + check(ag.bot_route({0: [], 1: []}, {0: (0, 0, 0), 1: (9, 0, 0)}, {1: 1}, 0) == [], + "an unreachable weapon node is no route") + + # Nearest by path, not by straight line: node 3 is beside node 0 in + # space but four links away; node 2 is further in space but closer. + positions = {0: (0, 0, 0), 1: (300, 0, 0), 2: (600, 0, 0), + 4: (300, 400, 0), 5: (0, 400, 0), 3: (0, 60, 0)} + adjacent = {0: [1], 1: [0, 2, 4], 2: [1], 4: [1, 5], 5: [4, 3], 3: [5]} + check(ag.bot_route(adjacent, positions, {2: 1, 3: 1}, 0) == [0, 1, 2], + "the pathfinder measures along the links, not across") + + +def test_slots(): + print("linking and unlinking slots") + check(ag.link_slots([4, E, E, E], 9) == [4, 9, E, E], "takes the first free slot") + check(ag.link_slots([4, 9, E, E], 9) == [4, 9, E, E], "already linked: unchanged") + check(ag.link_slots([], 3) == [3, E, E, E], "pads missing slots") + try: + ag.link_slots([1, 2, 3, 4], 5) + check(False, "a fifth neighbour is refused") + except ag.AiGraphError: + check(True, "a fifth neighbour is refused") + check(ag.unlink_slots([4, 9, 7, E], 9) == [4, 7, E, E], + "unlinking moves the rest up, as retail packs them") + check(ag.unlink_slots([4, E, E, E], 9) == [4, E, E, E], "unlinking a stranger does nothing") + check(ag.free_slots([1, E, 2, E]) == 2 and ag.free_slots([1, 2]) == 2 + and ag.free_slots([1, 2, 3, 4]) == 0, "counts free slots") + + +def test_link_targets(): + print("where a new weapon node links") + candidates = [(0, (0, 0, 0), 2), (1, (100, 0, 0), 2), (2, (200, 0, 0), 0), + (3, (5000, 0, 0), 3)] + check(ag.link_targets((90, 0, 0), candidates) == [1, 0], + "the two nearest, nearest first") + check(ag.link_targets((190, 0, 0), candidates) == [1, 0], + "skipping a node with no free slot") + check(ag.link_targets((4000, 0, 0), candidates) == [3], + "and anything beyond %d units" % ag.AUTO_LINK_RANGE) + check(ag.link_targets((90, 0, 0), candidates, count=1) == [1], + "as many as the new node has room for") + check(ag.link_targets((0, 0, 0), []) == [], "no graph, no links") + + +def test_reach(): + print("balloons on their nodes") + nodes = [(0, 0, 0), (1000, 0, 0)] + balloons = [(30, 0, 0), (1000, 0, 80), (5000, 0, 0)] + far = ag.balloons_off_their_node(nodes, balloons) + check([index for index, _d in far] == [1, 2], + "a balloon more than %d from its nearest node is reported (%r)" + % (ag.WEAPON_REACH, far)) + check(abs(far[0][1] - 80.0) < 1e-6, "with the distance") + check(ag.balloons_off_their_node([], balloons) == [], "no nodes, nothing to say") + + found = ag.weapon_reach([(4, (0, 0, 0), 1), (5, (500, 0, 0), 0), + (6, (900, 0, 0), 1)], [(20, 0, 0)]) + check([(n, round(d)) for n, d in found] == [(4, 20), (6, 880)], + "weapon reach measures each class-1 node to its nearest balloon") + check(ag.weapon_reach([(4, (0, 0, 0), 1)], []) == [(4, None)], + "with no balloon the distance is None") + check(ag.describe_class(5) == "Bank node of racer 1" + and ag.describe_class(1) == "Weapon node", "classes read in words") + + +def test_view(): + print("the overlay's geometry") + nodes, positions = chain(5) + views = [view.NodeView(i, positions[i], 1 if i in (0, 4) else 0, slots) + for i, slots in nodes] + red = view.BALLOON_COLOURS["BALLOON_TYPE_MISSILE"] + balloons = [((10.0, 0.0, 0.0), red)] + + plain = view.build(views, balloons) + check(len(plain["links"][0]) == 8 and len(plain["links"][1]) == 8, + "four links, two vertices each, one colour per vertex") + check(all(c == view.LINK_COLOUR for c in plain["links"][1]), + "with no node active, links are neutral") + check(plain["reach"] == {0: True, 4: False}, + "node 0 has a balloon in reach, node 4 not (%r)" % plain["reach"]) + circles = plain["circles"][1] + half = len(circles) // 2 + check(circles[:half] == [view.IN_REACH] * half + and circles[half:] == [view.OUT_OF_REACH] * half, + "ground circles green where a balloon is in reach, red where not") + radius = math.dist(plain["circles"][0][0][:2], positions[0][:2]) + check(abs(radius - ag.WEAPON_REACH) < 1e-6, "of radius %d" % ag.WEAPON_REACH) + sizes = [layer[0] for layer in plain["points"]] + check(sizes == sorted(sizes, reverse=True), "point layers biggest first") + ring = [c for size, _p, colours in plain["points"] + if size == view.NODE_SIZE + view.RING_WIDTH for c in colours] + gap = [c for size, _p, colours in plain["points"] + if size == view.NODE_SIZE + view.RING_GAP for c in colours] + check(gap and all(c == view.RING_CORE for c in gap), + "a dark gap separates each ring from its dot") + check(red in ring, "node 0's ring takes the nearest balloon's colour") + check(view.UNKNOWN_BALLOON in ring, "node 4, with none near, is white") + check(plain["route"] == [], "no node active, no route") + check([text for _p, text, _c in plain["labels"]] == [view.NO_BALLOON] + and plain["labels"][0][0] == positions[4], + "the only words are under node 4, which has no balloon in reach") + + lit = view.build(views, balloons, active=2, show_ids=True) + colours = dict(zip(lit["links"][0], lit["links"][1])) + check(colours[positions[1]] == view.heat(0.0), + "the active node's neighbours are the hot end of the ramp") + check(colours[positions[0]] == view.heat(1.0), "the furthest the cold end") + check(lit["route_ids"] == [2, 1, 0], "the route goes to the nearest weapon node") + check(lit["route"] and len(lit["route"]) % 2 == 0, "drawn as dash pairs") + dashed = sum(math.dist(a, b) for a, b in zip(lit["route"][::2], lit["route"][1::2])) + check(0 < dashed < 200.0, "dashes cover part of the 200-unit route (%.0f)" % dashed) + ids = [text for _p, text, _c in lit["labels"] if text != view.NO_BALLOON] + check(ids == ["0", "1", "2", "3", "4"], "an id per node (%r)" % ids) + big = [p for size, pos, _c in lit["points"] if size == view.NEIGHBOUR_SIZE for p in pos] + check(positions[1] in big and positions[3] in big, + "direct neighbours are drawn bigger") + + far = view.build(views, balloons, active=2, mode=view.DISTANCE) + check(far["route_ids"] == [2, 1, 0], "by distance too") + + broken = [view.NodeView(0, (0, 0, 0), 0, [1, 99, 255, 0]), + view.NodeView(1, (100, 0, 0), 0, [0]), + view.NodeView(2, (500, 0, 0), 0, []), + view.NodeView(None, (9, 9, 9), 0, [0])] + found = view.build(broken, (), active=0) + check(len(found["links"][0]) == 2, "links to missing ids and to itself are skipped") + grey = [c for size, pos, colours in found["points"] for p, c in zip(pos, colours) + if p == (500.0, 0.0, 0.0) and c != view.RIM_COLOUR] + check(grey == [view.UNREACHED], "an unreachable node is dim grey (%r)" % grey) + check(view.build([], ())["points"] == [], "an empty scene draws nothing") + check(view.build(views, (), active=77)["route_ids"] == [], + "an active id that is not a node is ignored") + check(view.heat(-1) == view.HEAT_STOPS[0][1] and view.heat(2) == view.HEAT_STOPS[-1][1], + "the ramp clamps") + + +def main(): + test_adjacency() + test_heat() + test_bot_route() + test_slots() + test_link_targets() + test_reach() + test_view() + print() + if FAILURES: + print("%d FAILURE(S)" % len(FAILURES)) + return 1 + print("PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/blender/tests/test_blender_ai_graph.py b/tools/blender/tests/test_blender_ai_graph.py new file mode 100644 index 00000000..42f72e20 --- /dev/null +++ b/tools/blender/tests/test_blender_ai_graph.py @@ -0,0 +1,393 @@ +"""Exercise the arena AI graph tools inside Blender. + +The pure rules are checked by ``test_ai_graph.py``; this proves the Blender +end: a weapon balloon placed in Battle brings its weapon node, parented to it +and linked into the graph when there is one; the node survives the graph being +redrawn and Refresh Artwork; Link and Unlink write both nodes; the export +writes a parented node at its world position with its links; and the overlay +builds its geometry in a background Blender, where there is no GPU to draw +it with. + + blender --background --python tools/blender/tests/test_blender_ai_graph.py +""" + +from __future__ import annotations + +import math +import os +import sys +import tempfile +import traceback + +import bpy + +_HERE = os.path.dirname(os.path.abspath(__file__)) +for argument in sys.argv: + if argument.endswith("test_blender_ai_graph.py"): + _HERE = os.path.dirname(os.path.abspath(argument)) + break + +sys.path.insert(0, os.path.abspath(os.path.join(_HERE, ".."))) +sys.path.insert(0, _HERE) + +import track_lab # noqa: E402 +from track_lab import ai_graph, gltf_io, mode_checklist as mc, prefs, scene # noqa: E402 +from track_lab.operators import ai as ai_ops # noqa: E402 +from track_lab.operators import mode_checklist as checklist_ops # noqa: E402 + +FAILURES = [] +E = ai_graph.NO_NEIGHBOUR + + +def check(condition, message): + if condition: + print(" ok %s" % message) + else: + print(" FAIL %s" % message) + FAILURES.append(message) + + +def fresh(level_type="CHALLENGE", challenge="BATTLE"): + bpy.ops.wm.read_factory_settings(use_empty=True) + settings = bpy.context.scene.dkr + settings.level_type = level_type + if level_type == "CHALLENGE": + settings.challenge_type = challenge + + +def nodes(): + return ai_ops.node_empties(bpy.context) + + +def by_id(): + return {int(n["nodeID"]): n for n in nodes()} + + +def slots(obj): + return [int(v) for v in obj["adjacent"]] + + +def linked_to(obj): + return sorted(v for v in slots(obj) if v != E) + + +def place_balloon(where, preset="RED"): + bpy.context.scene.cursor.location = where + result = bpy.ops.dkr.place_object(object_id=mc.BALLOON, preset=preset) + return result, bpy.context.active_object + + +def circle_curve(radius, points=16): + data = bpy.data.curves.new("line", type="CURVE") + data.dimensions = "3D" + spline = data.splines.new("POLY") + spline.points.add(points - 1) + for i, point in enumerate(spline.points): + angle = 2.0 * math.pi * i / points + point.co = (radius * math.cos(angle), radius * math.sin(angle), 0.0, 1.0) + spline.use_cyclic_u = True + curve = bpy.data.objects.new("line", data) + bpy.context.scene.collection.objects.link(curve) + bpy.context.view_layer.objects.active = curve + return curve + + +def select(*objects): + for obj in bpy.context.selected_objects: + obj.select_set(False) + for obj in objects: + obj.select_set(True) + bpy.context.view_layer.objects.active = objects[-1] if objects else None + + +def reciprocal(): + found = {node_id: linked_to(n) for node_id, n in by_id().items()} + return all(a in found.get(b, []) for a, near in found.items() for b in near) + + +# --------------------------------------------------------------------------- + +def test_registration(): + print("registration") + for name in ("ai_link_nodes", "ai_unlink_nodes", "ai_link_weapon_nodes", + "ai_select_balloon"): + check(hasattr(bpy.ops.dkr, name), "dkr.%s is registered" % name) + settings = bpy.context.scene.dkr + check(settings.auto_weapon_node and not settings.show_ai_graph, + "Auto Weapon Node starts on, the overlay off") + + +def test_weapon_node_before_the_graph(): + print("a balloon placed before the graph, then the graph drawn") + fresh() + result, balloon = place_balloon((850.0, 0.0, 0.0)) + check(result == {"FINISHED"}, "the balloon is placed") + found = ai_ops.weapon_nodes_of(balloon) + check(len(found) == 1, "it brought one AI node with it") + node = found[0] + check(int(node["unk8"]) == ai_graph.CLASS_WEAPON, "of destination class 1") + check(node.name.startswith("Weapon Node · Red Balloon"), + "named for its balloon (%s)" % node.name) + check(scene.slot_of(node) == scene.SLOT_STRUCTURE, "in the structure map") + check(linked_to(node) == [], "unlinked, since there is no graph yet") + bpy.context.view_layer.update() + gap = (node.matrix_world.translation - balloon.matrix_world.translation).length + check(gap < 1e-4, "sitting on the balloon (%.4f)" % gap) + check(bpy.context.active_object == balloon, "the balloon stays the active object") + loose = {row.rid: row for row in checklist_ops.checklist(bpy.context)} + check("ai_graph" in loose and loose["ai_graph"].status == mc.MISSING, + "the checklist still asks for a graph") + + circle_curve(1200.0) + check(bpy.ops.dkr.ai_from_curve(spacing=300.0) == {"FINISHED"}, + "AI Line From Curve samples the arena") + found = ai_ops.weapon_nodes_of(balloon) + check(len(found) == 1 and found[0] == node, "the weapon node survived the rewrite") + ids = sorted(by_id()) + check(ids == list(range(len(ids))), "with an id after the new nodes, none shared (%r)" + % ids) + check(len(linked_to(node)) == ai_graph.AUTO_LINK_COUNT, + "and linked to the %d nearest (%r)" % (ai_graph.AUTO_LINK_COUNT, linked_to(node))) + check(reciprocal(), "every link is written both ways") + + # Splicing a branch rewrites every node; the weapon node must come back + # on its balloon. + branch = bpy.data.curves.new("branch", type="CURVE") + branch.dimensions = "3D" + spline = branch.splines.new("POLY") + spline.points.add(2) + for point, co in zip(spline.points, ((1200, 0, 0), (0, 300, 0), (-1200, 0, 0))): + point.co = (co[0], co[1], co[2], 1.0) + curve = bpy.data.objects.new("branch", branch) + bpy.context.scene.collection.objects.link(curve) + bpy.context.view_layer.objects.active = curve + check(bpy.ops.dkr.ai_add_branch(spacing=300.0) == {"FINISHED"}, "a branch is added") + found = ai_ops.weapon_nodes_of(balloon) + check(len(found) == 1 and found[0].name.startswith("Weapon Node · Red Balloon") + and int(found[0]["unk8"]) == 1, + "and the weapon node is still on its balloon, by name and class") + + +def test_weapon_node_into_a_graph(): + print("a balloon placed beside an existing graph") + fresh() + circle_curve(1200.0) + bpy.ops.dkr.ai_from_curve(spacing=300.0) + before = len(nodes()) + result, balloon = place_balloon((0.0, 1150.0, 0.0), preset="BLUE") + node = ai_ops.weapon_nodes_of(balloon)[0] + check(len(nodes()) == before + 1 and int(node["nodeID"]) == before, + "a weapon node with the next free id (%d)" % int(node["nodeID"])) + check(node.name.startswith("Weapon Node · Blue Balloon"), "named Blue") + near = linked_to(node) + check(len(near) == 2, "linked to two nodes (%r)" % near) + graph_nodes = by_id() + nearest = sorted(graph_nodes, key=lambda i: ( + graph_nodes[i].matrix_world.translation - node.matrix_world.translation).length) + check(set(near) == set(nearest[1:3]), "the two nearest (%r, %r)" % (near, nearest[1:3])) + check(reciprocal(), "both ways") + + # The export: a parented node at its world position, with its links. + balloon.location.x += 40.0 + bpy.context.view_layer.update() + gap = (node.matrix_world.translation - balloon.matrix_world.translation).length + check(gap < 1e-4, "moving the balloon carries its node") + temporary = tempfile.mkdtemp(prefix="dkr-ai-") + target = os.path.join(temporary, "objects.gltf") + check(bpy.ops.dkr.export_object_map(filepath=target) == {"FINISHED"}, + "the object map exports") + written = gltf_io.load(target) + exported = [o for o in written.objects if o.object_id == mc.AINODE + and o.fields.get("nodeID") == int(node["nodeID"])] + check(len(exported) == 1, "the weapon node is in it") + if exported: + want = scene.to_map(balloon.matrix_world.translation) + got = exported[0].translation + check(all(abs(a - b) < 1e-3 for a, b in zip(got, want)), + "at the balloon's world position, not its local one (%r vs %r)" + % (got, want)) + check(sorted(a for a in exported[0].fields["adjacent"] if a != E) == near, + "with its links") + check(exported[0].fields.get("unk8") == 1, "and destination class 1") + check(exported[0].name == "ai-node" or "Weapon" not in exported[0].name, + "under the type's node name, not the outliner label (%s)" + % exported[0].name) + + # Ctrl+Z inside a placing session goes through _remove_last, which takes + # the balloon's node with it. + from track_lab.operators.edit import DKR_OT_place_object # noqa: PLC0415 + + class Session: + _placed = [balloon.name] + report = staticmethod(lambda *_args: None) + + count = len(nodes()) + DKR_OT_place_object._remove_last(Session, bpy.context) + check(len(nodes()) == count - 1, "taking the balloon back takes its node too") + + bpy.context.scene.dkr.auto_weapon_node = False + place_balloon((0.0, -1150.0, 0.0)) + check(not ai_ops.weapon_nodes_of(bpy.context.active_object), + "with Auto Weapon Node off, a balloon comes alone") + bpy.context.scene.dkr.auto_weapon_node = True + + fresh("CHALLENGE", "EGGS") + place_balloon((0.0, 0.0, 0.0)) + check(not nodes(), "an egg arena gets no weapon node: egg bots use no graph") + fresh("RACE") + place_balloon((0.0, 0.0, 0.0)) + check(not nodes(), "nor does a race") + + +def test_refresh_keeps_weapon_nodes(): + print("refresh artwork keeps a weapon node on its balloon") + if prefs.resolve(bpy.context) is None: + print(" skip: no decomp assets") + return + fresh() + _result, balloon = place_balloon((300.0, 0.0, 0.0), preset="GREEN") + bpy.ops.dkr.refresh_artwork() + balloons = [o for o in scene.iter_dkr_objects(bpy.context) if ai_ops.is_balloon(o)] + node = nodes()[0] + check(len(balloons) == 1 and ai_ops.balloon_of(node) == balloons[0], + "the node is parented to the rebuilt balloon") + bpy.context.view_layer.update() + gap = (node.matrix_world.translation - balloons[0].matrix_world.translation).length + check(gap < 1e-3, "and still sits on it (%.4f)" % gap) + check(node.name.startswith("Weapon Node · Green Balloon"), "keeping its name") + + +def test_link_and_unlink(): + print("linking two nodes by hand") + fresh() + circle_curve(1200.0) + bpy.ops.dkr.ai_from_curve(spacing=300.0) + graph = by_id() + a, b = graph[0], graph[5] + select(a) + check(not bpy.ops.dkr.ai_link_nodes.poll(), "one node is not enough") + select(a, b) + check(bpy.ops.dkr.ai_link_nodes.poll(), "two unlinked nodes can be linked") + check(not bpy.ops.dkr.ai_unlink_nodes.poll(), "and cannot be unlinked") + check(bpy.ops.dkr.ai_link_nodes() == {"FINISHED"}, "Link Nodes runs") + check(5 in linked_to(a) and 0 in linked_to(b), "each holds the other") + check(not bpy.ops.dkr.ai_link_nodes.poll(), "linking them again is refused") + check(reciprocal(), "every link is reciprocal") + check(bpy.ops.dkr.ai_unlink_nodes() == {"FINISHED"}, "Unlink Nodes runs") + check(5 not in linked_to(a) and 0 not in linked_to(b), "and removes both sides") + check(slots(a)[:2] != [E, E] and slots(a)[-1] == E, "the slots stay packed") + + full = graph[8] + full["adjacent"] = [7, 9, 1, 2] + select(full, graph[12]) + check(not bpy.ops.dkr.ai_link_nodes.poll(), "a node with four neighbours is refused") + + +def test_link_weapon_nodes(): + print("Link Weapon Nodes, for nodes left loose") + fresh() + circle_curve(1200.0) + bpy.ops.dkr.ai_from_curve(spacing=300.0) + _result, balloon = place_balloon((1150.0, 0.0, 0.0)) + node = ai_ops.weapon_nodes_of(balloon)[0] + for other in list(linked_to(node)): + ai_ops.unlink(node, by_id()[other]) + rows = {row.rid: row for row in checklist_ops.checklist(bpy.context)} + check(rows.get("weapon_links") is not None + and rows["weapon_links"].status == mc.MISSING, + "the checklist reports the loose weapon node") + check(bpy.ops.dkr.ai_link_weapon_nodes.poll(), "and offers Link Weapon Nodes") + check(bpy.ops.dkr.ai_link_weapon_nodes() == {"FINISHED"}, "which runs") + check(len(linked_to(node)) == 2 and reciprocal(), "and links it both ways") + check(not bpy.ops.dkr.ai_link_weapon_nodes.poll(), "then has nothing left to do") + rows = {row.rid: row for row in checklist_ops.checklist(bpy.context)} + check("weapon_links" not in rows and rows["weapon_reach"].ok, + "and the checklist is satisfied") + + # A weapon node moved off its balloon is advice in the checklist. + node.location.x += 400.0 + bpy.context.view_layer.update() + rows = {row.rid: row for row in checklist_ops.checklist(bpy.context)} + check(rows["weapon_reach"].status == mc.ADVISE, + "a weapon node dragged off its balloon is flagged") + + +def test_tag_warning(): + print("Tag Destinations warns about a balloon far from every node") + fresh() + circle_curve(1200.0) + bpy.ops.dkr.ai_from_curve(spacing=300.0) + bpy.context.scene.dkr.auto_weapon_node = False + place_balloon((0.0, 0.0, 0.0)) + reports = [] + try: + result = bpy.ops.dkr.ai_tag_destinations() + except RuntimeError as error: + result = None + reports.append(str(error)) + check(result == {"FINISHED"}, "it still tags the nearest node") + check(any(int(n["unk8"]) == 1 for n in nodes()), "a node became class 1") + + +def test_overlay_geometry(): + print("the overlay's geometry, without a GPU") + fresh() + circle_curve(1200.0) + bpy.ops.dkr.ai_from_curve(spacing=300.0) + _result, balloon = place_balloon((1150.0, 0.0, 0.0)) + settings = bpy.context.scene.dkr + settings.show_ai_graph = True + settings.ai_graph_show_ids = True + plain = ai_ops.graph_geometry(bpy.context) + count = len(nodes()) + check(sum(len(p) for _s, p, _c in plain["points"]) >= count, + "a dot for each of the %d nodes" % count) + check(plain["reach"] and all(plain["reach"].values()), + "the weapon node has its balloon in reach") + check(len(plain["labels"]) == count, "and an id label each") + start = by_id()[4] + select(start) + lit = ai_ops.graph_geometry(bpy.context) + route = lit["route_ids"] + weapon = int(ai_ops.weapon_nodes_of(balloon)[0]["nodeID"]) + check(route and route[0] == 4 and route[-1] == weapon, + "from node 4 the route ends at the weapon node (%r)" % route) + check(lit is not plain, "selecting a node rebuilt the geometry") + check(ai_ops.graph_geometry(bpy.context) is lit, "and nothing changed: cached") + ai_ops._draw_graph() # no GPU context here: must not raise + ai_ops._draw_graph_labels() + check(True, "the draw handlers return quietly in the background") + ok = bpy.types.DKR_PT_ai.poll(bpy.context) + check(ok, "the AI Node Graph panel shows in Battle") + + +def main(): + track_lab.register() + try: + test_registration() + test_weapon_node_before_the_graph() + test_weapon_node_into_a_graph() + test_refresh_keeps_weapon_nodes() + test_link_and_unlink() + test_link_weapon_nodes() + test_tag_warning() + test_overlay_geometry() + finally: + track_lab.unregister() + print() + if FAILURES: + print("FAIL: %d check(s)" % len(FAILURES)) + for line in FAILURES: + print(" " + line) + return 1 + print("PASS: all AI graph checks") + return 0 + + +if __name__ == "__main__": + try: + _code = main() + except BaseException: # noqa: BLE001 - an uncaught error must not read as a pass + traceback.print_exc() + _code = 1 + sys.exit(_code) diff --git a/tools/blender/tests/test_blender_modes.py b/tools/blender/tests/test_blender_modes.py new file mode 100644 index 00000000..48c4dd1b --- /dev/null +++ b/tools/blender/tests/test_blender_modes.py @@ -0,0 +1,338 @@ +"""Exercise the Mode Checklist's buttons and the arena AI tools inside Blender. + +The pure rules are checked by ``test_mode_checklist.py``; this proves the +buttons that act on them work in a scene: adding per-racer chests, flags and +Head-For Points where the start ring says each racer's go, tagging AI nodes +for weapons and banks, splicing a branch without flattening the graph, and a +hub exit that leads to another custom track reaching the package as a link. + + blender --background --python tools/blender/tests/test_blender_modes.py +""" + +from __future__ import annotations + +import json +import math +import os +import shutil +import struct +import sys +import tempfile +import traceback +import zlib + +import bpy + +_HERE = os.path.dirname(os.path.abspath(__file__)) +for argument in sys.argv: + if argument.endswith("test_blender_modes.py"): + _HERE = os.path.dirname(os.path.abspath(argument)) + break + +sys.path.insert(0, os.path.abspath(os.path.join(_HERE, ".."))) +sys.path.insert(0, _HERE) + +import track_lab # noqa: E402 +from track_lab import level_links, mode_checklist as mc, prefs, scene # noqa: E402 +from track_lab.operators import mode_checklist as checklist_ops # noqa: E402 + +FAILURES = [] + + +def check(condition, message): + if condition: + print(" ok %s" % message) + else: + print(" FAIL %s" % message) + FAILURES.append(message) + + +def fresh(level_type, challenge="BATTLE"): + bpy.ops.wm.read_factory_settings(use_empty=True) + settings = bpy.context.scene.dkr + settings.level_type = level_type + if level_type == "CHALLENGE": + settings.challenge_type = challenge + + +def of_type(object_id): + return [o for o in scene.iter_dkr_objects(bpy.context) + if str(o.get(scene.PROP_ID)) == object_id] + + +def rows(): + return {row.rid: row for row in checklist_ops.checklist(bpy.context)} + + +def circle_curve(radius, points=16, name="line"): + data = bpy.data.curves.new(name, type="CURVE") + data.dimensions = "3D" + spline = data.splines.new("POLY") + spline.points.add(points - 1) + for i, point in enumerate(spline.points): + angle = 2.0 * math.pi * i / points + point.co = (radius * math.cos(angle), radius * math.sin(angle), 0.0, 1.0) + spline.use_cyclic_u = True + curve = bpy.data.objects.new(name, data) + bpy.context.scene.collection.objects.link(curve) + bpy.context.view_layer.objects.active = curve + return curve + + +def run(operator, **kwargs): + """An operator's result, with the RuntimeError an ERROR report raises.""" + try: + return operator(**kwargs) + except RuntimeError as error: + return {"ERROR: %s" % error} + + +# --------------------------------------------------------------------------- + +def test_registration(): + print("registration") + for name in ("add_racer_set", "set_exit_destination", "ai_tag_destinations"): + check(hasattr(bpy.ops.dkr, name), "dkr.%s is registered" % name) + check("DKR_PT_mode_checklist" in dir(bpy.types), "the Mode Checklist panel is registered") + + +def test_bananas_arena(): + print("a banana arena from the checklist's buttons") + fresh("CHALLENGE", "BANANAS") + check(not bpy.ops.dkr.add_racer_set.poll(), + "Add Chests waits for the start ring, which says where each goes") + check(rows()["chests"].status == mc.MISSING, "chests start missing") + + bpy.ops.dkr.generate_start_grid() + check(rows()["start"].ok, "Build Start Ring fixes the start row") + check(bpy.ops.dkr.add_racer_set(kind="CHESTS") == {"FINISHED"}, "Add Chests runs") + chests = of_type(mc.CHEST) + check(sorted(int(c["playerID"]) for c in chests) == [1, 2, 3, 4], + "four chests, playerID 1-4") + starts = checklist_ops.racer_starts(bpy.context) + nearest_ok = True + for chest in chests: + racer = int(chest["playerID"]) - 1 + where = chest.matrix_world.translation + closest = min(starts, key=lambda r: (starts[r].matrix_world.translation - where).length) + nearest_ok &= closest == racer + check(nearest_ok, "each chest is nearest its own racer's start") + check(rows()["chests"].ok, "the chests row is met") + check(bpy.ops.dkr.add_racer_set(kind="CHESTS") == {"CANCELLED"}, + "pressing it again adds nothing") + + bpy.data.objects.remove(chests[0], do_unlink=True) + bpy.ops.dkr.add_racer_set(kind="CHESTS") + check(len(of_type(mc.CHEST)) == 4, "a deleted chest is put back alone") + + bpy.ops.dkr.add_racer_set(kind="FLAGS") + flags = of_type(mc.FLAG) + check(sorted(int(f["playerIndex"]) for f in flags) == [0, 1, 2, 3], + "four flags, playerIndex 0-3") + over = True + for flag in flags: + chest = next(c for c in of_type(mc.CHEST) + if int(c["playerID"]) == int(flag["playerIndex"]) + 1) + offset = flag.matrix_world.translation - chest.matrix_world.translation + over &= abs(offset.x) < 1e-3 and abs(offset.y) < 1e-3 and offset.z > 100 + check(over, "each flag stands over its racer's chest") + + bpy.context.scene.cursor.location = (0.0, 0.0, 0.0) + bpy.ops.dkr.place_object(object_id=mc.COIN_CREATOR) + # On the diagonals, away from the chests, which win a node a balloon + # also wants. + for x in (-850.0, 850.0): + bpy.context.scene.cursor.location = (x, x, 0.0) + bpy.ops.dkr.place_object(object_id=mc.BALLOON, preset="RED") + check(rows()["ai_graph"].status == mc.MISSING, "the AI graph is still missing") + + circle_curve(1200.0) + check(bpy.ops.dkr.ai_from_curve(spacing=300.0) == {"FINISHED"}, + "AI Line From Curve samples the arena") + check(rows()["ai_graph"].ok, "and meets the graph row") + check(rows()["bank_nodes"].status == mc.MISSING, "bank nodes are not tagged yet") + check(bpy.ops.dkr.ai_tag_destinations() == {"FINISHED"}, "Tag Destinations runs") + classes = sorted(int(n["unk8"]) for n in of_type(mc.AINODE) if int(n["unk8"])) + check(classes == [1, 1, 4, 5, 6, 7], + "two weapon nodes and one bank node per racer (%r)" % classes) + current = rows() + check(all(row.ok or row.status == mc.NOTE for row in current.values()), + "every row is met (%s)" % [r for r, row in current.items() if not row.ok]) + + # A branch must not flatten the classes just set. + branch = bpy.data.curves.new("branch", type="CURVE") + branch.dimensions = "3D" + spline = branch.splines.new("POLY") + spline.points.add(2) + for point, co in zip(spline.points, ((1200, 0, 0), (0, 300, 0), (-1200, 0, 0))): + point.co = (co[0], co[1], co[2], 1.0) + curve = bpy.data.objects.new("branch", branch) + bpy.context.scene.collection.objects.link(curve) + bpy.context.view_layer.objects.active = curve + before = len(of_type(mc.AINODE)) + check(bpy.ops.dkr.ai_add_branch(spacing=300.0, unk8=0) == {"FINISHED"}, + "Add Branch splices a second curve on") + after = of_type(mc.AINODE) + kept = sorted(int(n["unk8"]) for n in after if int(n["unk8"])) + check(len(after) > before and kept == classes, + "and every node keeps its destination class (%r)" % kept) + links = {int(n["nodeID"]): [a for a in n["adjacent"] if a != 255] for n in after} + check(all(a in links.get(b, []) for a, near in links.items() for b in near), + "every link is still reciprocal") + + result = run(bpy.ops.dkr.validate) + errors = [r.message for r in bpy.context.scene.dkr.results if r.severity == "error"] + check(not errors, "the finished arena validates without errors (%s)" % errors[:2]) + + +def test_eggs_arena(): + print("an egg arena") + fresh("CHALLENGE", "EGGS") + bpy.ops.dkr.generate_start_grid() + check(bpy.ops.dkr.add_racer_set(kind="HEAD_FOR") == {"FINISHED"}, + "Add Head-For Points runs") + points = of_type(mc.HEAD_FOR) + pairs = sorted((int(p["unk8"]), int(p["unk9"])) for p in points) + check(pairs == sorted([(k, r) for k in (0, 2) for r in range(4)]), + "one kind-0 and one kind-2 point per racer (%r)" % pairs) + bpy.ops.dkr.add_racer_set(kind="FLAGS") + flags_ok = True + for flag in of_type(mc.FLAG): + drop = next(p for p in points if int(p["unk8"]) == 0 + and int(p["unk9"]) == int(flag["playerIndex"])) + offset = flag.matrix_world.translation - drop.matrix_world.translation + flags_ok &= abs(offset.x) < 1e-3 and abs(offset.y) < 1e-3 + check(flags_ok, "each flag stands over its racer's nest point") + current = rows() + check(current["nests"].status == mc.UNKNOWN, + "with no geometry the nest floors are unknown") + check(current["egg_creator"].status == mc.MISSING + and current["balloons"].severity == mc.ERROR, + "the egg creator and a balloon are still missing") + run(bpy.ops.dkr.validate) + errors = [r.message for r in bpy.context.scene.dkr.results if r.severity == "error"] + check(any("Egg Creator" in m for m in errors), "validate names the egg creator") + check(not bpy.types.DKR_PT_ai.poll(bpy.context), + "the AI Node Graph panel stays out of an egg arena") + + +def test_ai_panel_visibility(): + print("where the AI Node Graph panel shows") + for level_type, challenge, shown in (("CHALLENGE", "BATTLE", True), + ("CHALLENGE", "BANANAS", True), + ("HUB", "BATTLE", True), + ("RACE", "BATTLE", False), + ("BOSS", "BATTLE", False)): + fresh(level_type, challenge) + check(bool(bpy.types.DKR_PT_ai.poll(bpy.context)) == shown, + "%s %s" % ("shown in" if shown else "hidden in", + challenge if level_type == "CHALLENGE" else level_type)) + fresh("RACE") + bpy.ops.dkr.place_object(object_id="ASSET_OBJECT_MODECHANGE", preset="LOOP_ENTRY") + check(bpy.types.DKR_PT_ai.poll(bpy.context), + "shown in a race once it has a loop, the one thing there that reads it") + + +def test_hub_exit_link(): + print("a hub exit to another custom track") + fresh("HUB") + bpy.ops.dkr.generate_start_grid() + bpy.context.scene.cursor.location = (500.0, 0.0, 0.0) + bpy.ops.dkr.place_object(object_id=level_links.EXIT) + exit_object = of_type(level_links.EXIT)[0] + bpy.context.view_layer.objects.active = exit_object + check(rows()["destinations"].status == mc.MISSING, "a new exit leads nowhere") + check(checklist_ops.describe_destination(exit_object) == "", + "and the panel says so") + + result = run(bpy.ops.dkr.set_exit_destination, kind="CUSTOM", track="Frosty Ridge") + check(result != {"FINISHED"}, "a track name that is not an id is refused") + check(bpy.ops.dkr.set_exit_destination(kind="CUSTOM", track="frosty-ridge") + == {"FINISHED"}, "a custom track id is accepted") + check(scene.destination_track(exit_object) == "frosty-ridge", "and stored on the exit") + check(rows()["destinations"].ok, "the destination row is met") + exported = scene.read_object(exit_object, __import__( + "track_lab.catalog", fromlist=["load"]).load()) + check(exported.fields.get(level_links.TRACK_FIELD) == "frosty-ridge" + and exported.fields.get("destinationMapId") == level_links.PLACEHOLDER_LEVEL, + "the exported exit names the track and holds the placeholder") + + if prefs.resolve(bpy.context) is not None: + settings = bpy.context.scene.dkr + settings.track_id = "test-hub" + temporary = tempfile.mkdtemp(prefix="dkr-hub-") + try: + target = os.path.join(temporary, "test-hub.dkrmap") + result = run(bpy.ops.dkr.export_dkrmap, filepath=target, validate_first=True) + check(result == {"FINISHED"}, "the hub exports (%s)" % result) + with open(os.path.join(target, "manifest.json"), encoding="utf-8") as handle: + manifest = json.load(handle) + links = manifest.get("levelLinks", []) + check(len(links) == 1 and links[0]["track"] == "frosty-ridge", + "the manifest carries the link (%r)" % links) + if links: + slot = links[0]["slot"] + with open(os.path.join(target, "objects_%s.bin" % slot), "rb") as handle: + packed = handle.read() + blob = zlib.decompress(packed[5:], -15) + offset = links[0]["offset"] + check(blob[offset] == 0, "byte %d of the inflated %s map is the " + "placeholder level 0" % (offset, slot)) + entries = struct.unpack(">I", blob[:4])[0] + check(16 <= offset < 16 + entries, "inside the map's entries") + source = os.path.join(target, "source", "objects_%s.gltf" + % scene.slot_of(exit_object)) + with open(source, encoding="utf-8") as handle: + document = json.load(handle) + carried = [n["extras"] for n in document["nodes"][1:] + if n["extras"].get("id") == level_links.EXIT] + check(carried and carried[0].get(level_links.TRACK_FIELD) == "frosty-ridge", + "the source glTF keeps the destination track") + + # Back into a scene: the link survives an import, which replaces + # the scene's objects. + bpy.ops.dkr.import_object_map(filepath=source) + check(any(scene.destination_track(o) == "frosty-ridge" + for o in of_type(level_links.EXIT)), + "an imported exit comes back pointing at the track") + finally: + shutil.rmtree(temporary, ignore_errors=True) + else: + print(" skip: no asset tree, so no package to compile") + + exit_object = of_type(level_links.EXIT)[0] + bpy.context.view_layer.objects.active = exit_object + check(bpy.ops.dkr.set_exit_destination(kind="RETAIL", level="ASSET_LEVEL_FOSSILCANYON") + == {"FINISHED"}, "switching back to a retail level works") + check(not scene.destination_track(exit_object) + and exit_object["destinationMapId"] == "ASSET_LEVEL_FOSSILCANYON", + "and drops the link") + + +def main(): + track_lab.register() + try: + test_registration() + test_bananas_arena() + test_eggs_arena() + test_ai_panel_visibility() + test_hub_exit_link() + finally: + track_lab.unregister() + print() + if FAILURES: + print("FAIL: %d check(s)" % len(FAILURES)) + for line in FAILURES: + print(" " + line) + return 1 + print("PASS: all mode checks") + return 0 + + +if __name__ == "__main__": + try: + _code = main() + except BaseException: # noqa: BLE001 - an uncaught error must not read as a pass + traceback.print_exc() + _code = 1 + sys.exit(_code) diff --git a/tools/blender/tests/test_level_links.py b/tools/blender/tests/test_level_links.py new file mode 100644 index 00000000..2a8a6722 --- /dev/null +++ b/tools/blender/tests/test_level_links.py @@ -0,0 +1,236 @@ +"""Check the byte offsets a hub's links to other custom tracks name. + +The runtime writes a resolved level id into the byte a ``levelLinks`` entry +names, in the object map the game has just inflated. One byte off and it +rewrites some other field of some other object, so the offset is checked +against the encoder's own output rather than recomputed: an exit with a retail +destination must store that destination's id exactly at the offset the link +computation gives, and a linked exit must store the placeholder there. + + python tools/blender/tests/test_level_links.py +""" + +from __future__ import annotations + +import json +import os +import shutil +import struct +import sys +import tempfile +import zlib + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) +sys.path.insert(0, _HERE) + +from track_lab import ( # noqa: E402 + assets, catalog as catalog_module, dkrmap, gltf_io, level_links, + object_map_encoder, +) +from track_lab.gltf_io import MapObject, ObjectMap # noqa: E402 + +from test_roundtrip import REPO_ROOT # noqa: E402 + +FAILURES = [] + + +def check(condition, message): + if condition: + print(" ok %s" % message) + else: + print(" FAIL %s" % message) + FAILURES.append(message) + + +def obj(object_id, **fields): + return MapObject(object_id=object_id, name=object_id, + translation=[10.0, 20.0, 30.0], fields=fields) + + +def encoder_for(catalog, tree): + """The real translation table and asset index when there is a tree; else + the catalogue's own order, which is enough to place every byte.""" + if tree is not None: + return object_map_encoder.ObjectMapEncoder( + catalog, tree.translation_table(), tree.asset_index) + levels = list(catalog.levels) + return object_map_encoder.ObjectMapEncoder( + catalog, sorted(catalog.types), + lambda section, name: levels.index(name) if name in levels else -1) + + +def exit_to(destination, track=""): + fields = {"destinationMapId": destination, "overworldSpawnIndex": 0, + "returnSpawnIndex": 255, "radius": 1.5, "angleY": 0.0, + "bossFlag": "WARP_FLAG_NORMAL"} + if track: + fields[level_links.TRACK_FIELD] = track + return obj(level_links.EXIT, **fields) + + +def level_id(encoder, name): + return encoder.asset_index("ASSET_LEVEL_HEADERS", name) + + +def inflate(packed: bytes) -> bytes: + """The container is a u32 size and a tag, then raw deflate.""" + size, _tag = struct.unpack("I", blob[:4])[0] + check(16 <= link["offset"] < 16 + entries, + "and lies inside the map's entries") + check(links[1]["offset"] == 16 + 8 + 8 + 8, + "the collectables link counts the two 8-byte coins before it") + package.write() + with open(os.path.join(package.directory, "manifest.json"), encoding="utf-8") as handle: + written = json.load(handle) + check(written.get("levelLinks") == links, "manifest.json carries them") + + bare = dkrmap.TrackPackage(os.path.join(temporary, "plain.dkrmap"), + "plain", "Plain") + bare.encode_object_map("structure", ObjectMap(objects=[ + exit_to("ASSET_LEVEL_FOSSILCANYON")]), catalog, + tree.translation_table(), tree.asset_index) + check("levelLinks" not in bare.manifest(), + "a package without custom destinations has no levelLinks key") + finally: + shutil.rmtree(temporary, ignore_errors=True) + + +def test_retail_hub(catalog, tree): + print("a retail hub with one exit relinked") + if tree is None: + print(" skip: no extracted asset tree") + return + level = next((l for l in tree.levels() if l.label == "Dino Domain Hub"), None) + if level is None: + print(" skip: Dino Domain Hub not in this extraction") + return + retail = gltf_io.load(level.objects_path) + exits = [i for i, o in enumerate(retail.objects) if o.object_id == level_links.EXIT] + chosen = exits[len(exits) // 2] + objects = [MapObject(o.object_id, o.name, list(o.translation), dict(o.fields)) + for o in retail.objects] + original = objects[chosen].fields["destinationMapId"] + objects[chosen].fields[level_links.TRACK_FIELD] = "my-race" + edited = level_links.with_placeholders(ObjectMap(objects=objects)) + encoder = encoder_for(catalog, tree) + links = level_links.links(edited, encoder.entry_offsets(edited), "structure", catalog) + before = encoder.encode(retail) + after = encoder.encode(edited) + changed = [i for i, (a, b) in enumerate(zip(before, after)) if a != b] + check(len(links) == 1 and changed == [links[0]["offset"]], + "relinking exit %d of %d changes exactly the byte its link names (%r vs %r)" + % (exits.index(chosen) + 1, len(exits), changed, [l["offset"] for l in links])) + check(before[links[0]["offset"]] == level_id(encoder, original), + "which held %s before" % original) + + +def main(): + catalog = catalog_module.load() + tree = assets.AssetTree.discover(REPO_ROOT) + test_offsets(catalog, encoder_for(catalog, tree)) + test_package(catalog, tree) + test_retail_hub(catalog, tree) + print() + if FAILURES: + print("%d FAILURE(S)" % len(FAILURES)) + return 1 + print("PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/blender/tests/test_mode_checklist.py b/tools/blender/tests/test_mode_checklist.py new file mode 100644 index 00000000..3e5264e9 --- /dev/null +++ b/tools/blender/tests/test_mode_checklist.py @@ -0,0 +1,375 @@ +"""Check the Mode Checklist: what challenges, hubs and boss races need. + +Synthetic maps pin each rule down - an empty arena misses everything, a +complete one misses nothing, and the edge the decomp is precise about (a +chest's playerID is 1-based and masked) reads the way the game reads it. Then, +when an extraction is available, every retail level of these kinds must pass +its own checklist: a rule that fails Smokey Castle is a wrong rule. + + python tools/blender/tests/test_mode_checklist.py +""" + +from __future__ import annotations + +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) +sys.path.insert(0, _HERE) + +from track_lab import ( # noqa: E402 + assets, catalog as catalog_module, gltf_io, level_links, level_types as lt, + mode_checklist as mc, validate, +) +from track_lab.gltf_io import MapObject, ObjectMap # noqa: E402 + +from test_roundtrip import REPO_ROOT # noqa: E402 + +FAILURES = [] + + +def check(condition, message): + if condition: + print(" ok %s" % message) + else: + print(" FAIL %s" % message) + FAILURES.append(message) + + +def obj(object_id, **fields): + return MapObject(object_id=object_id, name=object_id, translation=[0.0, 0.0, 0.0], + fields=fields) + + +def ring(): + return [obj(mc.SETUPPOINT, racerIndex=r, entranceID=0) for r in mc.RACERS] + + +def graph(count=6, classes=None): + """A closed loop of AI nodes, with destination classes by node id.""" + nodes = [] + for i in range(count): + nodes.append(obj(mc.AINODE, nodeID=i, unk8=(classes or {}).get(i, 0), + adjacent=[(i - 1) % count, (i + 1) % count, 255, 255], + elevation=0)) + return nodes + + +def by_rid(rows): + return {row.rid: row for row in rows} + + +def missing(rows): + return sorted(row.rid for row in rows if row.status == mc.MISSING) + + +# --------------------------------------------------------------------------- + +def test_applies(): + print("which level types have a checklist") + for key in (lt.BATTLE, lt.BANANAS, lt.EGGS, lt.HUB, lt.BOSS): + check(mc.applies(key), "%s has one" % key) + for key in (lt.RACE, lt.TEST_RACE, lt.CUTSCENE, lt.BACKDROP, None): + check(not mc.applies(key) and mc.rows(ObjectMap(), key) == [], + "%s has none" % key) + + +def test_battle(): + print("battle") + rows = mc.rows(ObjectMap(), lt.BATTLE) + check(missing(rows) == ["ai_graph", "balloons", "start"], + "an empty arena misses its ring, graph and balloons (%s)" % missing(rows)) + check(by_rid(rows)["ai_graph"].severity == mc.ERROR, + "no AI graph is an error: battle bots only move along it") + check("will not move" in by_rid(rows)["ai_graph"].message, + "and says the AI racers will not move") + + unlinked = [obj(mc.AINODE, nodeID=i, adjacent=[255] * 4) for i in range(5)] + rows = mc.rows(ObjectMap(objects=ring() + unlinked), lt.BATTLE) + check(by_rid(rows)["ai_graph"].status == mc.MISSING, + "nodes with no links are not a graph") + + complete = ring() + graph(classes={2: 1}) + [obj(mc.BALLOON)] + rows = mc.rows(ObjectMap(objects=complete), lt.BATTLE) + check(missing(rows) == [], "a complete arena misses nothing (%s)" % missing(rows)) + + rows = mc.rows(ObjectMap(objects=ring() + graph() + [obj(mc.BALLOON)]), lt.BATTLE) + row = by_rid(rows)["balloon_nodes"] + check(row.status == mc.MISSING and row.fix == mc.FIX_AI_TAG, + "a graph without a class-1 node offers Tag Destinations") + + +def test_weapon_nodes(): + print("weapon nodes: on a balloon, and on the graph") + + def at(item, x): + item.translation = [float(x), 0.0, 0.0] + return item + + nodes = graph(6, classes={1: 1, 4: 1}) + at(nodes[1], 0) + at(nodes[4], 900) + arena = ring() + nodes + [at(obj(mc.BALLOON), 40)] + rows = by_rid(mc.rows(ObjectMap(objects=arena), lt.BATTLE)) + row = rows["weapon_reach"] + check(row.status == mc.ADVISE and row.label == "Weapon nodes on a balloon 1/2", + "a weapon node with no balloon near is advice, not missing (%s, %s)" + % (row.status, row.label)) + check("node 4" in row.message and "node 4" in row.detail.lower(), + "and names the node (%s)" % row.message) + check(row.objects == [len(ring()) + 4], "and selects it") + check("weapon_links" not in rows, "a linked weapon node is not reported loose") + check(mc.summary(list(rows.values())) == "5 of 5 ready", + "advice counts as ready (%s)" % mc.summary(list(rows.values()))) + warned = [i for i in mc.issues(ObjectMap(objects=arena), lt.BATTLE) + if "node 4" in i[1]] + check(warned and warned[0][0] == mc.WARNING, + "validation reports it as a warning, never an error") + + at(nodes[4], 60) + rows = by_rid(mc.rows(ObjectMap(objects=arena), lt.BATTLE)) + check(rows["weapon_reach"].ok, + "within %d units counts as on it: retail's Darkwater Beach puts " + "balloons up to 78 away" % mc.ai_graph.WEAPON_REACH_CHECK) + + nodes[4].fields["adjacent"] = [255] * 4 + rows = by_rid(mc.rows(ObjectMap(objects=arena), lt.BATTLE)) + row = rows.get("weapon_links") + check(row is not None and row.status == mc.MISSING and row.fix == mc.FIX_AI_LINK + and row.severity == mc.WARNING, + "an unlinked weapon node is a warning with Link Weapon Nodes") + check(row is not None and "node 4" in row.message and row.label == "Weapon nodes linked 1/2", + "naming it (%s)" % (row.label if row else None)) + + rows = by_rid(mc.rows(ObjectMap(objects=ring() + graph(6, classes={1: 1})), lt.BATTLE)) + check("weapon_reach" not in rows, + "with no balloon at all only the balloon row speaks") + + +def test_bananas(): + print("bananas") + chests = [obj(mc.CHEST, playerID=r + 1) for r in mc.RACERS] + flags = [obj(mc.FLAG, playerIndex=r) for r in mc.RACERS] + nodes = graph(8, classes={0: 1, 1: 4, 3: 5, 5: 6, 7: 7}) + complete = ring() + chests + flags + nodes + [obj(mc.COIN_CREATOR), obj(mc.BALLOON)] + rows = mc.rows(ObjectMap(objects=complete), lt.BANANAS) + check(missing(rows) == [], "a complete banana arena misses nothing (%s)" + % missing(rows)) + + # playerID is racer + 1, masked: the game reads (playerID - 1) & 3, so a + # chest numbered 0 belongs to racer 3 and two chests numbered 2 leave + # someone without. + check(mc.owner(mc.CHEST, 0) == 3 and mc.owner(mc.CHEST, 5) == 0, + "a chest's owner is (playerID - 1) & 3") + shifted = [obj(mc.CHEST, playerID=v) for v in (0, 1, 2, 2)] + rows = mc.rows(ObjectMap(objects=ring() + shifted), lt.BANANAS) + row = by_rid(rows)["chests"] + check(row.status == mc.MISSING and "racer 2" in row.message, + "chests numbered 0, 1, 2, 2 leave racer 2 without one, as the game " + "reads them (%s)" % row.message) + check(mc.missing_racers(ObjectMap(objects=shifted), mc.CHEST, "playerID") == [2], + "the Add button adds only racer 2's chest") + check(row.severity == mc.ERROR, "a missing chest is an error: that racer cannot win") + + rows = mc.rows(ObjectMap(objects=ring() + chests + [obj(mc.COIN)]), lt.BANANAS) + check(by_rid(rows)["bananas"].severity == mc.WARNING, + "loose bananas without a creator are a warning, not an error") + rows = mc.rows(ObjectMap(objects=ring() + chests), lt.BANANAS) + check(by_rid(rows)["bananas"].severity == mc.ERROR, + "no banana at all is an error") + + rows = mc.rows(ObjectMap(objects=ring() + chests + graph(classes={0: 1, 1: 4})), + lt.BANANAS) + row = by_rid(rows)["bank_nodes"] + check(row.status == mc.MISSING and row.label == "Bank nodes 1/4", + "bank nodes are counted per racer (%s)" % row.label) + + +def test_eggs(): + print("eggs") + heads = [] + for r in mc.RACERS: + heads.append(obj(mc.HEAD_FOR, unk8=mc.HEAD_DROP, unk9=r)) + heads.append(obj(mc.HEAD_FOR, unk8=mc.HEAD_HOME, unk9=r)) + flags = [obj(mc.FLAG, playerIndex=r) for r in mc.RACERS] + complete = ring() + heads + flags + [obj(mc.EGG_CREATOR), obj(mc.BALLOON)] + rows = mc.rows(ObjectMap(objects=complete), lt.EGGS, + surfaces=set(mc.NEST_SURFACES) | {"SURFACE_DEFAULT"}) + check(missing(rows) == [], "a complete egg arena misses nothing (%s)" % missing(rows)) + check("ai_graph" not in by_rid(rows) and by_rid(rows)["no_graph"].status == mc.NOTE, + "eggs asks for no AI graph and says why") + + rows = mc.rows(ObjectMap(objects=complete), lt.EGGS) + check(by_rid(rows)["nests"].status == mc.UNKNOWN, + "without geometry the nests are unknown, not missing") + check(not any("SURFACE_EGG" in message for _s, message, _o, _w in + mc.issues(ObjectMap(objects=complete), lt.EGGS)), + "and validation stays quiet about them") + + rows = mc.rows(ObjectMap(objects=complete), lt.EGGS, + surfaces={"SURFACE_EGG_01", "SURFACE_EGG_03"}) + row = by_rid(rows)["nests"] + check(row.status == mc.MISSING and "SURFACE_EGG_02" in row.message + and "SURFACE_EGG_04" in row.message and row.severity == mc.ERROR, + "a racer whose nest surface is missing is named (%s)" % row.message) + + rows = mc.rows(ObjectMap(objects=ring() + [obj(mc.EGG_CREATOR)]), lt.EGGS) + check(by_rid(rows)["head_for"].severity == mc.ERROR + and "freezes" in by_rid(rows)["head_for"].message, + "no kind-0 Head-For Point is an error: an egg carrier searches forever") + check(by_rid(rows)["balloons"].severity == mc.ERROR, + "no weapon balloon is an error in eggs: an idle bot searches forever") + + partial = ring() + heads[:6] + [obj(mc.EGG_CREATOR), obj(mc.BALLOON)] + rows = mc.rows(ObjectMap(objects=partial), lt.EGGS) + row = by_rid(rows)["head_for"] + check(row.status == mc.MISSING and row.severity == mc.WARNING + and "Racer 3" in row.message, + "one racer without its pair is a warning naming it (%s)" % row.message) + check(mc.missing_racers(ObjectMap(objects=partial), mc.HEAD_FOR, "unk9", + {"unk8": mc.HEAD_HOME}) == [3], + "the Add button fills in only racer 3's points") + + +def test_hub(): + print("hub") + entrances = [obj(mc.SETUPPOINT, racerIndex=0, entranceID=e) for e in (0, 1)] + rows = mc.rows(ObjectMap(), lt.HUB) + check(missing(rows) == ["entrance0", "exits"], "an empty hub misses entrance 0 " + "and any way out (%s)" % missing(rows)) + check(not by_rid(rows)["entrance0"].reported, + "entrance 0 is left to the start grid's own validation") + + exits = [ + obj(mc.EXIT, destinationMapId="ASSET_LEVEL_FOSSILCANYON", returnSpawnIndex=1), + obj(mc.EXIT, returnSpawnIndex=255), + obj(mc.EXIT, destinationMapId="ASSET_LEVEL_CENTRALAREAHUB", + returnSpawnIndex=255, **{level_links.TRACK_FIELD: "frosty-ridge"}), + obj(mc.EXIT, destinationMapId="ASSET_LEVEL_ANCIENTLAKE", returnSpawnIndex=4), + ] + rows = mc.rows(ObjectMap(objects=entrances + exits), lt.HUB) + row = by_rid(rows)["destinations"] + check(row.status == mc.MISSING and row.objects == [3] and row.severity == mc.ERROR, + "the one exit with no destination is an error, and is selectable " + "(%r)" % row.objects) + check(by_rid(rows)["returns"].objects == [5] + and "entrance 4" in by_rid(rows)["returns"].message, + "an exit returning to an entrance the hub lacks is named") + + rows = mc.rows(ObjectMap(objects=entrances + [obj(mc.TAJ)]), lt.HUB) + check({"npc_graph", "taj_course"} <= set(missing(rows)), + "Taj asks for a graph to walk and a course to race (%s)" % missing(rows)) + + +def test_boss(): + print("boss") + pair = [obj(mc.SETUPPOINT, racerIndex=r, entranceID=0) for r in (0, 1)] + checkpoints = [obj(mc.CHECKPOINT, index=i) for i in range(4)] + rows = mc.rows(ObjectMap(objects=pair + checkpoints), lt.BOSS, + boss="BOSS_RACE_BUBBLER1") + check(missing(rows) == [], "a boss race with a pair and checkpoints is ready") + check("hovercraft" in by_rid(rows)["boss"].detail, + "the boss row names the vehicle the race is run in") + rows = mc.rows(ObjectMap(objects=pair), lt.BOSS, boss="NOT_A_BOSS") + check(by_rid(rows)["boss"].status == mc.MISSING, "an unknown boss is caught") + check(by_rid(rows)["checkpoints"].severity == mc.ERROR, + "no checkpoints is an error in a boss race") + + +def test_validation_mirrors_the_checklist(catalog): + print("validation reports what the checklist misses") + arena = ObjectMap(objects=ring() + [obj(mc.COIN_CREATOR)]) + report = validate.validate(arena, catalog, level_key=lt.BANANAS) + messages = [i.message for i in report.errors] + check(any("will not move" in m for m in messages), + "bananas without a graph: AI racers will not move") + check(any("treasure chest" in m for m in messages), + "bananas without chests is an error") + + report = validate.validate(ObjectMap(objects=ring()), catalog, level_key=lt.EGGS) + check(any("Egg Creator" in i.message for i in report.errors), + "eggs without an egg creator is an error") + + pair = [obj(mc.SETUPPOINT, racerIndex=r, entranceID=0) for r in (0, 1)] + report = validate.validate(ObjectMap(objects=pair), catalog, + level_key=lt.BOSS, boss="BOSS_RACE_TRICKY1") + errors = [i.message for i in report.errors] + check(any("No checkpoints" in m for m in errors), + "a boss race without checkpoints is an error") + check(not any("no checkpoints; laps" in i.message for i in report.warnings), + "and is not also reported as the race warning") + + hub = ObjectMap(objects=[obj(mc.SETUPPOINT, racerIndex=0, entranceID=0), + obj(mc.EXIT, returnSpawnIndex=255)]) + report = validate.validate(hub, catalog, level_key=lt.HUB) + check(any("lead nowhere" in i.message for i in report.errors), + "a hub exit with no destination is an error") + check(not any("no destinationMapId" in i.message for i in report), + "and is not also the generic exit warning") + + linked = ObjectMap(objects=[ + obj(mc.SETUPPOINT, racerIndex=0, entranceID=0), + obj(mc.EXIT, destinationMapId=level_links.PLACEHOLDER_LEVEL, + returnSpawnIndex=255, **{level_links.TRACK_FIELD: "Frosty Ridge"})]) + report = validate.validate(linked, catalog, level_key=lt.HUB) + check(any("not a track id" in i.message for i in report.errors), + "a custom destination that is not a track id is an error") + + +def test_retail_levels_pass(tree): + print("retail levels pass their own checklist") + import json + failed = [] + advice = [] + seen = 0 + for level in tree.levels(): + key = lt.key_for_race_type(level.race_type) + if not mc.applies(key): + continue + objects = [] + for path in level.object_maps: + objects += gltf_io.load(path).objects + merged = ObjectMap(objects=objects) + if key == lt.HUB and not merged.count_of(mc.EXIT): + continue # the cutscene levels that borrow the hub race type + with open(level.header_path, "r", encoding="utf-8") as handle: + boss = json.load(handle).get("boss-race-id") + seen += 1 + for row in mc.rows(merged, key, boss=boss): + if row.status == mc.MISSING: + failed.append("%s: %s" % (level.label, row.label)) + if row.status == mc.ADVISE: + advice.append("%s: %s" % (level.label, row.label)) + check(seen >= 20, "checked %d retail challenges, hubs and boss races" % seen) + check(advice == ["Smokey Castle: Weapon nodes on a balloon 1/3"], + "only Smokey Castle draws weapon-node advice, for its two class-1 " + "nodes far from any balloon (%s)" % advice) + check(not failed, "none misses anything (%s)" % "; ".join(failed[:6])) + + +def main(): + catalog = catalog_module.load() + test_applies() + test_battle() + test_weapon_nodes() + test_bananas() + test_eggs() + test_hub() + test_boss() + test_validation_mirrors_the_checklist(catalog) + tree = assets.AssetTree.discover(REPO_ROOT) + if tree is None: + print("SKIP: no extracted asset tree, so the retail checks did not run") + else: + test_retail_levels_pass(tree) + print() + if FAILURES: + print("%d FAILURE(S)" % len(FAILURES)) + return 1 + print("PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/blender/tests/test_race_ai.py b/tools/blender/tests/test_race_ai.py index 67c2dd21..ad8882cf 100644 --- a/tools/blender/tests/test_race_ai.py +++ b/tools/blender/tests/test_race_ai.py @@ -8,7 +8,8 @@ * **The spline is the game's.** It passes through every lane point, and a racer on the alternate route leaves and rejoins the main line at gates. * **Loading follows ``checkpoint_update_all``** - vehicle set filter, sort on - ``index + 255`` for alternates, pairing by index, the 60-gate ceiling. + ``index + 255`` for alternates, pairing by index, the 127-gate ceiling + DKR-R patches in and the 60 retail and older builds keep. * **Every retail map builds**, every set of it, into finite lines. * **The header bytes are the right bytes.** Each pointer lands on the offset the game reads, the two mislabelled ``ai-levels`` slots included, and an @@ -33,6 +34,7 @@ from track_lab import ( # noqa: E402 catalog, gltf_io, level_header, level_header_template as template, race_ai, + validate, ) from test_roundtrip import VANILLA, find_object_maps # noqa: E402 @@ -133,9 +135,57 @@ def check_loading_rules(): if doubled.duplicates != [0]: return "two gates on index 0 are a duplicate" - many = race_ai.build_route([_gate(i, 0, index=i) for i in range(65)]) + if race_ai.MAX_CHECKPOINTS != 127 or race_ai.RETAIL_MAX_CHECKPOINTS != 60: + return "DKR-R's table holds 127 gates a set and retail's 60" + many = race_ai.build_route([_gate(i, 0, index=i) for i in range(132)]) if len(many.main) != race_ai.MAX_CHECKPOINTS or many.dropped != 5: - return "the game loads 60 gates a set and drops the rest" + return "DKR-R loads 127 gates a set and drops the rest" + if many.past_retail != race_ai.MAX_CHECKPOINTS - race_ai.RETAIL_MAX_CHECKPOINTS: + return "past_retail counts the loaded gates past retail's 60" + # Alternates count toward the same table, in spawn order. + mixed = [_gate(i, 0, index=i) for i in range(100)] + mixed += [_gate(i, 9, index=i, alternate=1) for i in range(30)] + mixed_route = race_ai.build_route(mixed) + if (len(mixed_route.main), len(mixed_route.alternates), mixed_route.dropped) != (100, 27, 3): + return "alternate gates share the main gates' 127 slots" + within = race_ai.build_route([_gate(i, 0, index=i) for i in range(60)]) + if within.dropped or within.past_retail: + return "60 gates fit everywhere" + return None + + +def check_validate_counts(): + """Validation says what loading does: a set past DKR-R's 127 is an error + that names the gates skipped, past retail's 60 a warning, at 60 nothing.""" + def count_issues(gates): + # The checkpoint rules alone: these gates carry only the line's fields. + report = validate._check_checkpoints(gltf_io.ObjectMap(objects=gates), False) + return [issue for issue in report if issue.severity != validate.INFO] + + if count_issues([_gate(i, 0, index=i) for i in range(60)]): + return "60 checkpoints in a set are fine everywhere" + + issues = count_issues([_gate(i, 0, index=i) for i in range(100)]) + if [issue.severity for issue in issues] != [validate.WARNING]: + return "100 checkpoints warn once: %r" % issues + if "older DKR-R builds" not in issues[0].message or issues[0].objects != list(range(60, 100)): + return "the warning names older builds and the 40 gates they skip" + + # Alternates share the table, and each vehicle type has its own. + gates = [_gate(i, 0, index=i) for i in range(120)] + gates += [_gate(i, 9, index=i, alternate=1) for i in range(10)] + gates += [_gate(i, 0, index=i, vehicle=1) for i in range(20)] + issues = count_issues(gates) + if [issue.severity for issue in issues] != [validate.ERROR]: + return "130 gates in set 0 and 20 in set 1 is one error: %r" % issues + message = issues[0].message + if "at most 127" not in message or "130 checkpoints" not in message: + return "the error says the count and the ceiling: %s" % message + if issues[0].objects != list(range(127, 130)): + return "the error selects the three gates past the ceiling" + + if count_issues([_gate(i, 0, index=i) for i in range(127)])[0].severity != validate.WARNING: + return "127 checkpoints still load on DKR-R" return None @@ -166,7 +216,7 @@ def check_retail_routes(): if not paths: print(" skip: no extracted object maps") return None - maps = sets = largest = dropped = unpaired = duplicated = 0 + maps = sets = largest = dropped = past_retail = unpaired = duplicated = 0 for path in paths: object_map = gltf_io.load(path) counts = race_ai.vehicle_sets(object_map.objects) @@ -178,6 +228,7 @@ def check_retail_routes(): sets += 1 largest = max(largest, len(route.main) + len(route.alternates)) dropped += route.dropped + past_retail += route.past_retail unpaired += len(route.unpaired) duplicated += len(route.duplicates) for lane in range(race_ai.LANES): @@ -193,7 +244,7 @@ def check_retail_routes(): print(" %d retail maps with checkpoints, %d sets, largest set %d gates, " "%d dropped, %d unpaired alternates, %d duplicate ids" % (maps, sets, largest, dropped, unpaired, duplicated)) - if dropped: + if dropped or past_retail or largest > race_ai.RETAIL_MAX_CHECKPOINTS: return "a retail set passes the 60-gate ceiling, so the ceiling is wrong" return None @@ -299,6 +350,7 @@ def check_skill_words(): ("lane offsets", check_lane_offsets), ("spline passes the gates", check_spline_passes_the_gates), ("loading rules", check_loading_rules), + ("validation counts", check_validate_counts), ("alternate line rejoins", check_alternate_line_rejoins), ("retail routes", check_retail_routes), ("header bytes", check_header_bytes), diff --git a/tools/blender/track_lab/__init__.py b/tools/blender/track_lab/__init__.py index afb99df2..22295553 100644 --- a/tools/blender/track_lab/__init__.py +++ b/tools/blender/track_lab/__init__.py @@ -37,8 +37,8 @@ def _module_classes(): """ from . import prefs, props from .operators import (ai, checks, custom_textures, edit, geometry, - header, io_objects, level_type, minimap, music, - new_track, pack, placeholders, race_ai, rom_assets, + header, io_objects, level_type, minimap, + mode_checklist, music, new_track, pack, placeholders, race_ai, rom_assets, skybox, start_grid, textures, water, waterfall) from .ui import panels @@ -58,6 +58,7 @@ def _module_classes(): classes += list(waterfall.CLASSES) classes += list(edit.CLASSES) classes += list(ai.CLASSES) + classes += list(mode_checklist.CLASSES) classes += list(race_ai.CLASSES) classes += list(minimap.CLASSES) classes += list(checks.CLASSES) @@ -197,19 +198,21 @@ def register(): bpy.types.TOPBAR_MT_file_import.append(_menu_import) bpy.types.TOPBAR_MT_file_export.append(_menu_export) - # The bot lines and the minimap are draw handlers, not registered + # The bot lines, the AI graph and the minimap are draw handlers, not registered # classes, so they need explicit lifecycle handling. - from .operators import minimap, race_ai + from .operators import ai as ai_ops, minimap, race_ai race_ai.register_overlay() + ai_ops.register_overlay() minimap.register_overlay() except Exception: # Never leave Blender half-registered: otherwise the next enable fails # on the first surviving class with "already registered as a subclass". try: - from .operators import minimap, race_ai + from .operators import ai as ai_ops, minimap, race_ai race_ai.unregister_overlay() + ai_ops.unregister_overlay() minimap.teardown() except Exception: # noqa: BLE001 - rollback must continue pass @@ -229,9 +232,10 @@ def unregister(): from . import props try: - from .operators import minimap, race_ai + from .operators import ai as ai_ops, minimap, race_ai race_ai.unregister_overlay() + ai_ops.unregister_overlay() minimap.teardown() except Exception: # noqa: BLE001 - unregistering must not fail pass diff --git a/tools/blender/track_lab/ai_graph.py b/tools/blender/track_lab/ai_graph.py index 580704ac..e16881ea 100644 --- a/tools/blender/track_lab/ai_graph.py +++ b/tools/blender/track_lab/ai_graph.py @@ -66,14 +66,19 @@ class AiGraphError(Exception): class AiNode: - __slots__ = ("node_id", "position", "elevation", "neighbours", "branch") + __slots__ = ("node_id", "position", "elevation", "neighbours", "branch", + "destination") def __init__(self, node_id: int, position: Vector3, elevation: int = 0, - branch: int = 0): + branch: int = 0, destination: int = 0): self.node_id = node_id self.position = position self.elevation = elevation self.branch = branch + #: The destination class (``unk8``) the arena AI's pathfinder targets + #: a node by: 0 an ordinary node, 1 a weapon balloon, 4 + racer a + #: banana chest. Kept per node, so rewriting the graph keeps it. + self.destination = destination self.neighbours: List[int] = [] @property @@ -97,13 +102,14 @@ def __init__(self): self.nodes: List[AiNode] = [] self._by_id = {} - def add(self, position: Vector3, elevation: int = 0, branch: int = 0) -> AiNode: + def add(self, position: Vector3, elevation: int = 0, branch: int = 0, + destination: int = 0) -> AiNode: if len(self.nodes) >= MAX_NODES: raise AiGraphError( "a track cannot hold more than %d AI nodes (nodeID is a byte and " "255 marks an empty link)" % MAX_NODES ) - node = AiNode(len(self.nodes), position, elevation, branch) + node = AiNode(len(self.nodes), position, elevation, branch, destination) self.nodes.append(node) self._by_id[node.node_id] = node return node @@ -183,7 +189,7 @@ def resample(points: Sequence[Vector3], spacing: float, def build_from_path(points: Sequence[Vector3], spacing: float, closed: bool = True, - elevation: int = 0) -> AiGraph: + elevation: int = 0, destination: int = 0) -> AiGraph: """Sample one curve into a chain of nodes, closed into a loop if asked.""" graph = AiGraph() samples = resample(points, spacing, closed=closed) @@ -195,7 +201,7 @@ def build_from_path(points: Sequence[Vector3], spacing: float, closed: bool = Tr "increase the spacing" % (spacing, len(samples), MAX_NODES) ) for sample in samples: - graph.add(sample, elevation=elevation) + graph.add(sample, elevation=elevation, destination=destination) for previous, node in zip(graph.nodes, graph.nodes[1:]): graph.link(previous, node) if closed and len(graph.nodes) > 2: @@ -204,7 +210,8 @@ def build_from_path(points: Sequence[Vector3], spacing: float, closed: bool = Tr def attach_branch(graph: AiGraph, points: Sequence[Vector3], spacing: float, - elevation: int = 0, branch: int = 1) -> List[AiNode]: + elevation: int = 0, branch: int = 1, + destination: int = 0) -> List[AiNode]: """Sample an alternate route and splice it onto the nodes it starts and ends near. The branch's own nodes are chained as usual; its two ends then link to @@ -221,7 +228,8 @@ def attach_branch(graph: AiGraph, points: Sequence[Vector3], spacing: float, ) existing = [node.node_id for node in graph.nodes] - added = [graph.add(s, elevation=elevation, branch=branch) for s in samples] + added = [graph.add(s, elevation=elevation, branch=branch, + destination=destination) for s in samples] for previous, node in zip(added, added[1:]): graph.link(previous, node) @@ -265,6 +273,269 @@ def reachable_from(graph: AiGraph, start: int = 0) -> set: return seen +def nearest_index(positions: Sequence[Vector3], target: Vector3) -> Optional[int]: + """The index of the position closest to ``target``, or ``None`` if empty.""" + best, best_distance = None, None + for index, position in enumerate(positions): + distance = _distance_squared(position, target) + if best_distance is None or distance < best_distance: + best, best_distance = index, distance + return best + + +def destination_tags(nodes: Sequence[Vector3], balloons: Sequence[Vector3], + chests: Sequence[Tuple[int, Vector3]], + current: Sequence[int]) -> List[Tuple[int, int]]: + """``(node index, destination class)`` to set so the arena AI has targets. + + The node nearest each weapon balloon becomes class 1, and the node + nearest racer ``r``'s chest class 4 + r - what ``func_8001CD28`` asks for + (``objects.c:7675``; ``racer.c:929``, ``:948``, ``:951``). A chest wins a + node a balloon also wants, and a node already holding a class other than + 0 is left alone, since an author set it. + """ + wanted = {} + for position in balloons: + index = nearest_index(nodes, position) + if index is not None: + wanted.setdefault(index, 1) + for racer, position in chests: + index = nearest_index(nodes, position) + if index is not None and 0 <= racer <= 3: + wanted[index] = 4 + racer + return sorted((index, value) for index, value in wanted.items() + if current[index] in (0, value) and current[index] != value) + + +def balloons_off_their_node(nodes: Sequence[Vector3], balloons: Sequence[Vector3], + reach: float = None) -> List[Tuple[int, float]]: + """``(balloon index, distance)`` for each balloon whose nearest node is + further than ``reach``. + + :func:`destination_tags` tags that nearest node whatever its distance, + and a bot drives to the node, not to the balloon: it switches to its + next target 50 units out (``racer.c:682``), so a balloon further than + that from its node is driven past rather than taken. + """ + reach = WEAPON_REACH if reach is None else reach + found = [] + if not nodes: + return found + for index, position in enumerate(balloons): + nearest = nearest_index(nodes, position) + distance = _distance(nodes[nearest], position) + if distance > reach: + found.append((index, distance)) + return found + + +# --------------------------------------------------------------------------- +# What the arena bots do with the graph +# --------------------------------------------------------------------------- +# +# ``racer_ai_challenge`` (``racer.c:682``) drives along straight lines between +# linked nodes. It starts at the node nearest the racer, picks its next node +# once within 300 units of the current one and turns toward it within 50. A +# bot with no weapon asks ``func_8001CD28`` (``objects.c:7675``) for the +# destination-class-1 node closest by path through the links, excluding the +# node it stands on, and takes the first step toward it; arriving still +# without a weapon, it asks again and so goes on to the next nearest one. +# Bots never look at a balloon itself: the balloon has to sit on the node. + +#: How close a bot comes to a node before it turns for the next one, and so +#: how near a balloon must be to its weapon node to be driven through. The +#: overlay draws this ring around each weapon node. +WEAPON_REACH = 50.0 + +#: What the checklist accepts as "on its node". Wider than the 50 a bot +#: reliably touches because retail is: Darkwater Beach puts balloons up to 78 +#: units from their class-1 node (most 20-50), Icicle Pyramid up to 28, and a +#: rule that flags retail would only teach authors to ignore it. +WEAPON_REACH_CHECK = 100.0 + +#: How far a new weapon node looks for graph nodes to link to. Retail arena +#: links are long - 330 to 3,600 units, the median 1,100-1,400 across +#: Darkwater Beach, Icicle Pyramid and Smokey Castle - so this reaches past +#: the median. It measures straight-line distance and does not know about +#: walls: a link through one makes the bot drive into it. +AUTO_LINK_RANGE = 1500.0 +AUTO_LINK_COUNT = 2 + +#: Destination classes, as ``unk8`` holds them. +CLASS_PLAIN = 0 +CLASS_WEAPON = 1 +CLASS_REENTRY = 3 +CLASS_BANK = 4 #: plus the racer, 0-3 + + +def describe_class(value) -> str: + """What a destination class means, in a few words.""" + if value == CLASS_PLAIN: + return "Plain node" + if value == CLASS_WEAPON: + return "Weapon node" + if value == CLASS_REENTRY: + return "Re-entry node (after an elevation change)" + if isinstance(value, int) and CLASS_BANK <= value <= CLASS_BANK + 3: + return "Bank node of racer %d" % (value - CLASS_BANK) + return "Destination class %s" % value + + +def adjacency(nodes) -> dict: + """``{node id: [neighbour ids]}`` from ``(node id, adjacent slots)`` pairs. + + Forgiving, because it reads whatever the scene holds while the author is + editing it: empty slots, ids no node has, a node naming itself and + repeated ids are dropped, and a node id seen twice keeps its first entry. + """ + present = [] + slots = {} + for node_id, adjacent in nodes: + if not isinstance(node_id, int) or node_id in slots: + continue + present.append(node_id) + slots[node_id] = list(adjacent or []) + known = set(present) + found = {} + for node_id in present: + seen = [] + for value in slots[node_id]: + if (isinstance(value, int) and value != NO_NEIGHBOUR and value in known + and value != node_id and value not in seen): + seen.append(value) + found[node_id] = seen + return found + + +def links(adjacent: dict) -> List[Tuple[int, int]]: + """Each link once, as ``(low id, high id)``, one-way links included.""" + found = set() + for node_id, neighbours in adjacent.items(): + for other in neighbours: + found.add((min(node_id, other), max(node_id, other))) + return sorted(found) + + +def steps_from(adjacent: dict, start: int) -> dict: + """``{node id: links away}`` for every node reachable from ``start``.""" + if start not in adjacent: + return {} + found = {start: 0} + frontier = [start] + while frontier: + following = [] + for node_id in frontier: + for other in adjacent.get(node_id, ()): + if other not in found: + found[other] = found[node_id] + 1 + following.append(other) + frontier = following + return found + + +def path_distances(adjacent: dict, positions: dict, start: int): + """``(distance, previous)`` from ``start`` along the links, as the + pathfinder measures it: Dijkstra over each link's straight length.""" + import heapq # noqa: PLC0415 + + if start not in adjacent or start not in positions: + return {}, {} + distance = {start: 0.0} + previous = {} + queue = [(0.0, start)] + while queue: + travelled, node_id = heapq.heappop(queue) + if travelled > distance.get(node_id, math.inf): + continue + for other in adjacent.get(node_id, ()): + if other not in positions: + continue + through = travelled + _distance(positions[node_id], positions[other]) + if through < distance.get(other, math.inf): + distance[other] = through + previous[other] = node_id + heapq.heappush(queue, (through, other)) + return distance, previous + + +def bot_route(adjacent: dict, positions: dict, classes: dict, start: int, + wanted: int = CLASS_WEAPON) -> List[int]: + """The node ids a weapon-less bot standing on ``start`` drives through: + to the class-``wanted`` node nearest by path, never ``start`` itself. + Empty when no such node can be reached.""" + distance, previous = path_distances(adjacent, positions, start) + targets = [(d, node_id) for node_id, d in distance.items() + if node_id != start and classes.get(node_id) == wanted] + if not targets: + return [] + _d, target = min(targets) + route = [target] + while route[-1] != start: + route.append(previous[route[-1]]) + route.reverse() + return route + + +def link_slots(slots: Sequence[int], other: int) -> List[int]: + """``slots`` with ``other`` written into the first empty one. + + Raises :class:`AiGraphError` when all four are taken. Already linked, the + slots come back unchanged. + """ + found = list(slots or [])[:MAX_NEIGHBOURS] + found += [NO_NEIGHBOUR] * (MAX_NEIGHBOURS - len(found)) + if other in found: + return found + if NO_NEIGHBOUR not in found: + raise AiGraphError("already has %d neighbours, the most the format " + "holds" % MAX_NEIGHBOURS) + found[found.index(NO_NEIGHBOUR)] = other + return found + + +def unlink_slots(slots: Sequence[int], other: int) -> List[int]: + """``slots`` without ``other``, the rest moved up, as retail packs them.""" + kept = [value for value in list(slots or [])[:MAX_NEIGHBOURS] + if value != other and value != NO_NEIGHBOUR] + return kept + [NO_NEIGHBOUR] * (MAX_NEIGHBOURS - len(kept)) + + +def free_slots(slots: Sequence[int]) -> int: + """Empty ``adjacent`` slots, counting missing ones as empty.""" + filled = [value for value in list(slots or [])[:MAX_NEIGHBOURS] + if value != NO_NEIGHBOUR] + return MAX_NEIGHBOURS - len(filled) + + +def link_targets(position: Vector3, candidates, count: int = AUTO_LINK_COUNT, + reach: float = AUTO_LINK_RANGE) -> List[int]: + """The ids a new node at ``position`` should link to. + + ``candidates`` are ``(node id, position, free slots)`` of the nodes + already in the graph; the nearest ``count`` within ``reach`` with a free + slot are taken. Straight-line distance only: it does not know about walls. + """ + ranked = sorted((_distance(where, position), node_id) + for node_id, where, room in candidates + if room > 0 and _distance(where, position) <= reach) + return [node_id for _d, node_id in ranked[:count]] + + +def weapon_reach(nodes, balloons: Sequence[Vector3]) -> List[Tuple[int, Optional[float]]]: + """``(node id, distance to the nearest balloon)`` per class-1 node. + + ``nodes`` are ``(node id, position, destination class)``. The distance is + ``None`` when there is no balloon at all. + """ + found = [] + for node_id, position, value in nodes: + if value != CLASS_WEAPON: + continue + nearest = min((_distance(position, b) for b in balloons), default=None) + found.append((node_id, nearest)) + return found + + def _distance(a: Vector3, b: Vector3) -> float: return math.sqrt(_distance_squared(a, b)) diff --git a/tools/blender/track_lab/ai_graph_view.py b/tools/blender/track_lab/ai_graph_view.py new file mode 100644 index 00000000..c7358d53 --- /dev/null +++ b/tools/blender/track_lab/ai_graph_view.py @@ -0,0 +1,288 @@ +"""What the AI graph overlay draws, worked out without Blender. + +:mod:`track_lab.operators.ai` turns this into GPU batches; everything that +decides *what* is drawn - which links, in what colour, how big each node, the +route a bot would take - is here, so it can be tested without a GPU and the +draw handler stays a thin loop. + +Positions are taken and returned in Blender space (Z up). Distances do not +care, since the map-to-Blender mapping is a pure axis swap, but the ground +circles are drawn flat in Blender's XY plane. + +What is drawn: + +* every link as a line, every node as a dot; +* a weapon node (destination class 1) as a ring in its balloon's colour, with + a ground circle of :data:`ai_graph.WEAPON_REACH` around it: green when a + weapon balloon is inside it, red when the bot would drive up to the node and + find nothing to take. At arena scale that circle is a few pixels across, so + a node with no balloon in reach also says so in words beside it; +* a bank node (class 4-7) as a ring in its racer's colour, a re-entry node + (class 3) in a quiet accent; +* with a node active, a heat map out from it - its neighbours red, then + orange, yellow, green and blue the further away, by links or by distance - + with whatever cannot be reached left dim grey, which is also how a + disconnected piece of the graph shows up; +* and from that node, dashed, the route a bot without a weapon drives: to the + nearest weapon node by path, never the one it is on. + +Deliberately free of ``bpy``. +""" + +from __future__ import annotations + +import math +from typing import Dict, List, Optional, Sequence, Tuple + +from . import ai_graph + +Vector3 = Tuple[float, float, float] +Colour = Tuple[float, float, float, float] + +STEPS = "STEPS" +DISTANCE = "DISTANCE" + +#: Weapon balloon colours by ``balloonType``, matching the Place buttons. +BALLOON_COLOURS: Dict[str, Colour] = { + "BALLOON_TYPE_MISSILE": (1.00, 0.24, 0.20, 1.0), + "BALLOON_TYPE_BOOST": (0.25, 0.55, 1.00, 1.0), + "BALLOON_TYPE_TRAP": (0.30, 0.90, 0.35, 1.0), + "BALLOON_TYPE_SHIELD": (1.00, 0.88, 0.20, 1.0), + "BALLOON_TYPE_MAGNET": (0.85, 0.40, 1.00, 1.0), +} +#: A weapon node whose balloon cannot be told. +UNKNOWN_BALLOON: Colour = (1.0, 1.0, 1.0, 1.0) + +#: Bank node rings, racer 0-3: apart from every balloon colour and the heat. +RACER_COLOURS: Tuple[Colour, ...] = ( + (0.20, 0.90, 0.95, 1.0), + (1.00, 0.55, 0.10, 1.0), + (0.95, 0.45, 0.80, 1.0), + (0.65, 0.60, 1.00, 1.0), +) +REENTRY_COLOUR: Colour = (0.85, 0.75, 0.50, 1.0) + +NODE_COLOUR: Colour = (0.80, 0.80, 0.82, 1.0) +LINK_COLOUR: Colour = (0.80, 0.80, 0.82, 0.75) +#: The middle of a ringed node with no heat colour, dark so the ring reads. +RING_CORE: Colour = (0.08, 0.08, 0.09, 1.0) +UNREACHED: Colour = (0.40, 0.40, 0.42, 0.55) +ACTIVE_COLOUR: Colour = (1.0, 1.0, 1.0, 1.0) +ROUTE_COLOUR: Colour = (1.0, 1.0, 1.0, 0.95) +IN_REACH: Colour = (0.30, 0.95, 0.40, 0.95) +OUT_OF_REACH: Colour = (1.00, 0.20, 0.18, 0.95) + +#: Point sizes in pixels, so the graph reads however far the view is out. +NODE_SIZE = 13.0 +NEIGHBOUR_SIZE = 18.0 +ACTIVE_SIZE = 20.0 +#: A ring is three discs: the ring colour, a dark gap, then the node's own +#: dot. The gap is what keeps a red balloon's ring apart from a red heat dot. +RING_GAP = 4.0 +RING_WIDTH = 12.0 #: how much wider the ring is than the dot inside it +#: A dark rim around every node, so a grey dot reads on a grey floor. +RIM = 3.0 +RIM_COLOUR: Colour = (0.0, 0.0, 0.0, 0.6) + +#: Beside a weapon node with no balloon in reach. +NO_BALLOON = "no balloon in reach" + +#: The dashes of the bot route, in world units. +DASH = 70.0 +GAP = 45.0 +CIRCLE_SEGMENTS = 40 + +#: Heat stops, near to far: the 3ds Max soft-selection ramp. +HEAT_STOPS: Tuple[Tuple[float, Colour], ...] = ( + (0.00, (1.00, 0.15, 0.10, 1.0)), + (0.25, (1.00, 0.55, 0.10, 1.0)), + (0.50, (1.00, 0.95, 0.20, 1.0)), + (0.75, (0.30, 0.90, 0.30, 1.0)), + (1.00, (0.25, 0.50, 1.00, 1.0)), +) + + +class NodeView: + """One AI node as the overlay sees it.""" + + __slots__ = ("node_id", "position", "destination", "adjacent", "colour") + + def __init__(self, node_id: int, position: Vector3, destination: int = 0, + adjacent: Sequence[int] = (), colour: Optional[Colour] = None): + self.node_id = node_id + self.position = tuple(float(c) for c in position) + self.destination = destination + self.adjacent = list(adjacent or []) + #: The ring colour of a weapon node, from the balloon it is parented + #: to; ``None`` to take the nearest balloon's. + self.colour = colour + + +def heat(t: float) -> Colour: + """The ramp colour at ``t``, 0 near and 1 far.""" + t = min(1.0, max(0.0, t)) + for (low, a), (high, b) in zip(HEAT_STOPS, HEAT_STOPS[1:]): + if t <= high: + f = (t - low) / (high - low) if high > low else 0.0 + return tuple(a[i] + (b[i] - a[i]) * f for i in range(4)) + return HEAT_STOPS[-1][1] + + +def _distance(a: Vector3, b: Vector3) -> float: + return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3))) + + +def ring_colour(node: NodeView, balloons) -> Optional[Colour]: + """The ring a node is drawn with, or ``None`` for a plain dot.""" + value = node.destination + if value == ai_graph.CLASS_WEAPON: + if node.colour is not None: + return node.colour + near = [(_distance(node.position, where), colour) for where, colour in balloons] + near = [entry for entry in near if entry[0] <= ai_graph.WEAPON_REACH] + return min(near)[1] if near else UNKNOWN_BALLOON + if isinstance(value, int) and ai_graph.CLASS_BANK <= value <= ai_graph.CLASS_BANK + 3: + return RACER_COLOURS[value - ai_graph.CLASS_BANK] + if value == ai_graph.CLASS_REENTRY: + return REENTRY_COLOUR + return None + + +def _dashes(points: Sequence[Vector3], dash: float = DASH, gap: float = GAP): + """A polyline cut into dashes, as ``LINES`` vertex pairs.""" + out = [] + on, left = True, dash + for start, end in zip(points, points[1:]): + length = _distance(start, end) + at = 0.0 + while length > 0.0 and at < length: + step = min(left, length - at) + if on: + a = tuple(start[i] + (end[i] - start[i]) * at / length for i in range(3)) + b = tuple(start[i] + (end[i] - start[i]) * (at + step) / length + for i in range(3)) + out += (a, b) + at += step + left -= step + if left <= 1e-6: + on = not on + left = dash if on else gap + return out + + +def _circle(centre: Vector3, radius: float, segments: int = CIRCLE_SEGMENTS): + points = [] + for i in range(segments): + for k in (i, i + 1): + angle = 2.0 * math.pi * k / segments + points.append((centre[0] + radius * math.cos(angle), + centre[1] + radius * math.sin(angle), centre[2])) + return points + + +def build(nodes: Sequence[NodeView], balloons=(), active: Optional[int] = None, + mode: str = STEPS, show_ids: bool = False) -> dict: + """Everything the overlay draws, as plain lists. + + ``balloons`` are ``(position, colour)`` of the weapon balloons. The + result holds ``links`` and ``circles`` (``LINES`` vertex pairs with a + colour per vertex), ``route`` (dash pairs, one colour), ``points`` - + ``(size, positions, colours)`` layers, biggest first so a ring's dot + lands on top of it - ``labels`` as ``(position, text, colour)``, and + ``route_ids`` and ``reach`` (``{weapon node id: balloon inside}``) for + the panel. + """ + by_id: Dict[int, NodeView] = {} + for node in nodes: + if isinstance(node.node_id, int): + by_id.setdefault(node.node_id, node) + adjacent = ai_graph.adjacency((n.node_id, n.adjacent) for n in by_id.values()) + positions = {node_id: node.position for node_id, node in by_id.items()} + classes = {node_id: node.destination for node_id, node in by_id.items()} + if active not in by_id: + active = None + + # The heat, 0 at the active node to 1 at the furthest it reaches. + shade: Dict[int, float] = {} + neighbours = set(adjacent.get(active, ())) if active is not None else set() + route: List[int] = [] + if active is not None: + if mode == DISTANCE: + found, _previous = ai_graph.path_distances(adjacent, positions, active) + far = max(found.values()) if found else 0.0 + shade = {k: (v / far if far > 0 else 0.0) for k, v in found.items()} + else: + found = ai_graph.steps_from(adjacent, active) + far = max(found.values()) if found else 0 + shade = {k: (0.0 if v <= 1 or far <= 1 else (v - 1) / float(far - 1)) + for k, v in found.items()} + route = ai_graph.bot_route(adjacent, positions, classes, active) + + def fill(node_id): + if active is None: + return None + if node_id == active: + return ACTIVE_COLOUR + if node_id not in shade: + return UNREACHED + return heat(shade[node_id]) + + link_pos, link_col = [], [] + for a, b in ai_graph.links(adjacent): + link_pos += (positions[a], positions[b]) + if active is None: + link_col += (LINK_COLOUR, LINK_COLOUR) + elif a in shade and b in shade: + link_col += (fill(a), fill(b)) + else: + link_col += (UNREACHED, UNREACHED) + + circle_pos, circle_col = [], [] + reach: Dict[int, bool] = {} + layers: Dict[float, Tuple[list, list]] = {} + + def dot(size, where, colour): + layer = layers.setdefault(size, ([], [])) + layer[0].append(where) + layer[1].append(colour) + + labels = [] + for node_id, node in sorted(by_id.items()): + ring = ring_colour(node, balloons) + if node.destination == ai_graph.CLASS_WEAPON: + inside = any(_distance(node.position, where) <= ai_graph.WEAPON_REACH + for where, _colour in balloons) + reach[node_id] = inside + points = _circle(node.position, ai_graph.WEAPON_REACH) + circle_pos += points + circle_col += [IN_REACH if inside else OUT_OF_REACH] * len(points) + + size = (ACTIVE_SIZE if node_id == active else + NEIGHBOUR_SIZE if node_id in neighbours else NODE_SIZE) + colour = fill(node_id) + dot(size + (RING_WIDTH if ring is not None else 0.0) + RIM, + node.position, RIM_COLOUR) + if ring is not None: + dot(size + RING_WIDTH, node.position, ring) + dot(size + RING_GAP, node.position, RING_CORE) + dot(size, node.position, colour or RING_CORE) + else: + dot(size, node.position, colour or NODE_COLOUR) + if show_ids: + labels.append((node.position, str(node_id), + UNREACHED if active is not None and node_id not in shade + else ACTIVE_COLOUR)) + if reach.get(node_id) is False: + labels.append((node.position, NO_BALLOON, OUT_OF_REACH)) + + route_pos = _dashes([positions[n] for n in route]) if len(route) > 1 else [] + return { + "links": (link_pos, link_col), + "circles": (circle_pos, circle_col), + "route": route_pos, + "points": [(size, layers[size][0], layers[size][1]) + for size in sorted(layers, reverse=True)], + "labels": labels, + "route_ids": route, + "reach": reach, + } diff --git a/tools/blender/track_lab/data/catalog.json b/tools/blender/track_lab/data/catalog.json index b5086eec..5649d480 100644 --- a/tools/blender/track_lab/data/catalog.json +++ b/tools/blender/track_lab/data/catalog.json @@ -439,6 +439,7 @@ "ctype": "u8", "default": 0, "kind": "int", + "label": "Destination class", "max": 255, "min": 0, "name": "unk8", @@ -3601,6 +3602,7 @@ "ctype": "s16", "default": 3, "kind": "int", + "label": "Racer (0-3)", "max": 32767, "min": -32768, "name": "playerIndex", @@ -5278,6 +5280,7 @@ "ctype": "s8", "default": 0, "kind": "int", + "label": "Kind (0 drop, 2 home)", "max": 127, "min": -128, "name": "unk8", @@ -5295,6 +5298,7 @@ "ctype": "s8", "default": 3, "kind": "int", + "label": "Racer (0-3)", "max": 127, "min": -128, "name": "unk9", @@ -8349,6 +8353,7 @@ "ctype": "s8", "default": 1, "kind": "int", + "label": "Racer (1-4)", "max": 127, "min": -128, "name": "playerID", diff --git a/tools/blender/track_lab/dkrmap.py b/tools/blender/track_lab/dkrmap.py index 3886c838..224fcb2c 100644 --- a/tools/blender/track_lab/dkrmap.py +++ b/tools/blender/track_lab/dkrmap.py @@ -35,7 +35,7 @@ import shutil from typing import Dict, List, Optional -from . import (gltf_io, level_header, level_model_encoder, +from . import (gltf_io, level_header, level_links, level_model_encoder, minimap as minimap_module, music_audio, music_sequence, object_map_encoder, rice_pack, textures as texture_module) from .gltf_io import ObjectMap @@ -190,6 +190,9 @@ def __init__(self, directory: str, track_id: str, name: str, author: str = "", #: A native sequence's bytes (set_sequence), written as they are. self.sequence: Optional[bytes] = None self.enhanced_water: Optional[Dict[str, object]] = None + #: slot -> the ``levelLinks`` its compiled map needs: hub exits that + #: lead to other custom tracks. See :mod:`.level_links`. + self.level_links: Dict[str, List[Dict[str, object]]] = {} self.notes: List[str] = [] # -- sources --------------------------------------------------------- @@ -252,9 +255,16 @@ def encode_object_map(self, slot: str, object_map: ObjectMap, catalog, "%r is not an object-map slot; expected %s" % (slot, " or ".join(sorted(OBJECT_MAP_SLOTS))) ) - payload = object_map_encoder.pack( - object_map, catalog, translation_table, asset_index - ) + # An exit to another custom track ships a placeholder level and a + # link naming the byte to patch, since that track's id is the + # runtime's to assign. Both come from the same encoder, so the offset + # is the one the bytes were written at. + object_map = level_links.with_placeholders(object_map) + encoder = object_map_encoder.ObjectMapEncoder( + catalog, translation_table, asset_index) + payload = encoder.pack(object_map) + self.level_links[slot] = level_links.links( + object_map, encoder.entry_offsets(object_map), slot, catalog) os.makedirs(self.directory, exist_ok=True) path = os.path.join(self.directory, OBJECT_MAP_SLOTS[slot]) with open(path, "wb") as handle: @@ -605,6 +615,12 @@ def manifest(self) -> Dict[str, object]: } if self.enhanced_water is not None: manifest["enhancedWater"] = self.enhanced_water + # Only for maps compiled now: a link describes bytes this package + # wrote, so a slot whose map was not compiled has none to offer. + linked = [link for slot in OBJECT_MAP_SLOTS if slot in self.object_maps + for link in self.level_links.get(slot, [])] + if linked: + manifest["levelLinks"] = linked return manifest def missing_sections(self) -> List[str]: diff --git a/tools/blender/track_lab/level_links.py b/tools/blender/track_lab/level_links.py new file mode 100644 index 00000000..f75ea897 --- /dev/null +++ b/tools/blender/track_lab/level_links.py @@ -0,0 +1,104 @@ +"""Hub exits that lead to other custom tracks. + +An ``ASSET_OBJECT_EXIT`` names where it leads in one byte, ``destinationMapId`` +(``LevelObjectEntry_Exit`` offset 0x08). A retail level's id is fixed, so an +exit to Fossil Canyon simply stores it. A ``.dkrmap`` track has no fixed id: the +runtime numbers custom tracks from the set installed, so the byte cannot be +known when the hub is exported. + +So such an exit carries the destination track's **id** instead, in the glTF as +:data:`TRACK_FIELD`, and is encoded with a harmless placeholder destination +(:data:`PLACEHOLDER_LEVEL`, the Central Area hub, level 0). The package's +manifest then lists where that byte sits: + +.. code-block:: json + + "levelLinks": [ + {"slot": "structure", "offset": 42, "track": "frosty-ridge"} + ] + +``offset`` is the byte's position in the slot's object map **as the game holds +it after inflating it**: the 16-byte map header (``u32`` entry byte count, then +12 bytes of padding - ``gObjectMap[index] + sizeof(uintptr_t)`` on an ``s32 *`` +in ``track_spawn_objects``, ``src/objects.c:980``), then the entries in order. +The runtime writes the resolved level id there before the exit spawns, and +leaves the placeholder when the track is not installed. Documented for track +authors in ``docs/CUSTOM_TRACKS.md``, *Hub doors*. + +Deliberately free of ``bpy``. +""" + +from __future__ import annotations + +from typing import Dict, List, Optional + +from .gltf_io import MapObject, ObjectMap + +EXIT = "ASSET_OBJECT_EXIT" +DESTINATION_FIELD = "destinationMapId" + +#: The glTF extra an exit names a custom destination track with. Not a field of +#: the entry - the encoder writes only catalogue fields - so it travels in the +#: source glTF and the manifest, never in the game's bytes. +TRACK_FIELD = "destinationTrack" + +#: What the byte holds until the runtime patches it: level 0, the Central Area +#: hub, so a link to a track that is not installed leads somewhere safe. +PLACEHOLDER_LEVEL = "ASSET_LEVEL_CENTRALAREAHUB" + + +def linked_track(obj: MapObject) -> str: + """The custom track an exit leads to, or ``""`` for a retail destination.""" + if obj.object_id != EXIT: + return "" + value = obj.fields.get(TRACK_FIELD) + return str(value).strip() if isinstance(value, str) else "" + + +def with_placeholders(object_map: ObjectMap) -> ObjectMap: + """A copy whose linked exits encode :data:`PLACEHOLDER_LEVEL`. + + Whatever ``destinationMapId`` a linked exit was left holding - the last + retail level picked before the author switched it to a custom track - it + must not ship, or a missing track would send the player there instead. + """ + objects = [] + for obj in object_map.objects: + if linked_track(obj): + fields = dict(obj.fields) + fields[DESTINATION_FIELD] = PLACEHOLDER_LEVEL + obj = MapObject(object_id=obj.object_id, name=obj.name, + translation=list(obj.translation), fields=fields) + objects.append(obj) + return ObjectMap(objects=objects, root_name=object_map.root_name) + + +def field_offset(catalog, object_id: str = EXIT, + field_name: str = DESTINATION_FIELD) -> int: + """Where a field sits inside its entry, from the catalogue's struct layout.""" + object_type = catalog.get(object_id) + for field in object_type.fields if object_type else (): + if field.name == field_name and getattr(field, "offset", None) is not None: + return int(field.offset) + raise KeyError("%s has no field %s in the catalogue" % (object_id, field_name)) + + +def links(object_map: ObjectMap, entry_offsets: List[int], slot: str, + catalog) -> List[Dict[str, object]]: + """The ``levelLinks`` entries for one slot's map. + + ``entry_offsets`` is :meth:`ObjectMapEncoder.entry_offsets` of the same + map: each entry's first byte in the inflated buffer, header included. + """ + if len(entry_offsets) != len(object_map.objects): + raise ValueError("one entry offset per object is needed") + found = [] + inside: Optional[int] = None + for obj, start in zip(object_map.objects, entry_offsets): + track = linked_track(obj) + if not track: + continue + if inside is None: + inside = field_offset(catalog) + found.append({"slot": slot, "offset": int(start) + inside, "track": track}) + return found diff --git a/tools/blender/track_lab/level_types.py b/tools/blender/track_lab/level_types.py index c9d97fe1..b34c31ce 100644 --- a/tools/blender/track_lab/level_types.py +++ b/tools/blender/track_lab/level_types.py @@ -527,8 +527,9 @@ def preset_for(object_id: str, fields) -> Optional[Preset]: "places a whole set at once.", CHECKPOINT: "Counts laps and places respawns. Racers pass them in index " "order.", - "ASSET_OBJECT_AINODE": "A point on the AI racing line, linked to up to four " - "neighbours.", + "ASSET_OBJECT_AINODE": "A node of the AI node graph the Battle and Bananas " + "bots, and a hub's T.T. and Taj, drive along, " + "linked to up to four neighbours.", "ASSET_OBJECT_GROUNDZIPPER": "A speed pad on the ground.", "ASSET_OBJECT_AIRZIPPERS": "Boost rings in the air, for planes.", "ASSET_OBJECT_WATERZIPPERS": "Boost pads on water, for hovercraft.", @@ -572,8 +573,26 @@ def auto_help(object_type) -> str: return text +#: Where the bots fetch weapons at AI nodes rather than at the balloons. +WEAPON_NODE_KEYS = (BATTLE, BANANAS) + + +def weapon_node_note(key: Optional[str], auto: bool) -> str: + """What a weapon balloon's Place button says about its AI node, if anything.""" + if key not in WEAPON_NODE_KEYS: + return "" + if auto: + return ("Bots never see balloons here: they fetch weapons at AI nodes " + "of destination class 1. Auto Weapon Node puts one on this " + "balloon, parented to it, and links it to the graph.") + return ("Bots never see balloons here, and Auto Weapon Node is off: no bot " + "comes for this one unless an AI node of destination class 1 sits " + "on it.") + + def place_description(object_type, key: Optional[str], - chosen: Optional[Preset] = None) -> str: + chosen: Optional[Preset] = None, + auto_weapon_node: bool = False) -> str: """The tooltip of one Place button: what it is, and whether it fits here.""" parts = [] if chosen is not None: @@ -583,6 +602,10 @@ def place_description(object_type, key: Optional[str], written = HELP.get(object_type.object_id) if written: parts.append(written) + if object_type.object_id == WEAPON_BALLOON: + note = weapon_node_note(key, auto_weapon_node) + if note: + parts.append(note) if chosen is None: parts.append(auto_help(object_type)) if not visible(object_type, key): diff --git a/tools/blender/track_lab/mode_checklist.py b/tools/blender/track_lab/mode_checklist.py new file mode 100644 index 00000000..0890aaff --- /dev/null +++ b/tools/blender/track_lab/mode_checklist.py @@ -0,0 +1,699 @@ +"""What each non-race level type needs before the game can play it. + +A race needs a grid and checkpoints, and the rest of the addon is built around +those. A challenge arena, a hub and a boss race each need a different handful +of objects, and missing one does not fail loudly: a banana arena whose racer 2 +has no chest simply never ends, an egg arena with no weapon balloon freezes the +moment a bot runs out of things to do. This module is the one list of those +needs, per level type, each read out of the decomp: + +* **Every challenge** spawns four racers (``objects.c:1175``) at entrance 0's + ``racerIndex`` 0-3 (``objects.c:1121-1136``). +* **Battle and Bananas** bots walk the AI node graph: ``racer_AI_pathing_inputs`` + sends only those two race types to ``racer_ai_challenge`` (``racer.c:609-624``), + which starts from the node nearest the racer (``racer.c:718``). Its + pathfinder (``func_8001CD28``, ``objects.c:7675``) targets nodes by their + destination class: 1 for weapon balloons (``racer.c:929``, ``:948``), and in + Bananas 4 + racer for that racer's chest (``racer.c:760-773``, ``:951``). +* **Battle** eliminates a racer whose bananas reach 0 and ends when three are + out (``objects.c:6166-6175``, ``:6192-6195``); the bananas go by being hit. +* **Bananas**: a racer banks at the chest whose ``playerID`` is its index + 1 + (``obj_init_treasuresucker`` stores ``(playerID - 1) & 3``, + ``object_functions.c:4204``), from within 225 units (``:4231``), and wins at + 10 banked (``:4295``). A Coin Creator spawns a banana and another 20 seconds + after it is taken (``:4318-4345``). The Character Flag over a chest shows whose + it is, 0-based (``:1082-1084``). +* **Eggs** bots do not use the graph (``racer_ai_eggs``, ``racer.c:1052``): they + steer straight at eggs, Head-For Points and weapon balloons. An Egg Creator + keeps one egg on it (``object_functions.c:818-836``). An egg lands in a nest + by touching collision surface ``SURFACE_EGG_01``..``04`` - the racer is the + surface minus ``SURFACE_EGG_01`` - and anywhere outside ``SURFACE_EGG_SPAWN`` + ..``SURFACE_EGG_04`` it breaks and goes back to its creator + (``object_functions.c:748-768``). Three hatched eggs win (``:797``). A bot + carrying an egg heads for a Head-For Point of kind 0 and drops it within 100 + units (``racer.c:1158-1159``, ``:1187-1189``, ``:1219-1223``, ``:1324-1328``); + kind 2 is a racer's way home (``racer.c:1190-1199``). The target search loops + until it finds something (``racer.c:1102``, ``:1257-1260``), so with no + kind-0 point, or no weapon balloon for an idle bot to fetch + (``racer.c:1144-1154``), it never returns. +* **Hub**: only the players are spawned (``objects.c:1165``), at the entrance + the level was entered through. An Exit fires when a player crosses its plane + inside its radius (``object_functions.c:2441-2457``); its bytes become the + level settings (``func_8006D968``, ``thread3_main.c:790-806``): + ``destinationMapId`` the level, + ``returnSpawnIndex`` the entrance of *this* hub the player comes back to. + T.T. and Taj walk the AI node graph (``object_functions.c:1362``, + ``:3025``); Taj's challenge races run + around the hub's checkpoints (``mode_init_taj_race``, ``objects.c:9715``). +* **Boss race**: two racers, the player and the boss (``objects.c:1179``), in + the vehicles the header's boss implies (``objects.c:1236-1258``); both steer + along the checkpoints like a race (``racer.c:622``). + +Each requirement is a :class:`Row`. The Mode Checklist panel draws them; the +validator reports the ones that are not met (:func:`issues`), so the two can +never disagree. + +Deliberately free of ``bpy``. +""" + +from __future__ import annotations + +import math +from typing import Dict, Iterable, List, Optional, Sequence, Set + +from . import ai_graph, level_links, level_types + +AINODE = "ASSET_OBJECT_AINODE" +SETUPPOINT = level_types.SETUPPOINT +CHECKPOINT = level_types.CHECKPOINT +BALLOON = level_types.WEAPON_BALLOON +CHEST = "ASSET_OBJECT_TREASURESUCKER" +FLAG = "ASSET_OBJECT_CHARACTERFLAG" +COIN = "ASSET_OBJECT_COIN" +COIN_CREATOR = "ASSET_OBJECT_COINCREATOR" +EGG_CREATOR = "ASSET_OBJECT_EGGCREATOR" +HEAD_FOR = "ASSET_OBJECT_HEADFORPOINT" +EXIT = level_links.EXIT +TT = "ASSET_OBJECT_STOPWATCHMAN" +TAJ = "ASSET_OBJECT_PARKWARDEN" + +#: Destination classes (AI node ``unk8``) the arena AI asks for. +CLASS_BALLOON = 1 +CLASS_BANK = 4 #: plus the racer's index + +#: Head-For Point kinds (``unk8``). +HEAD_DROP = 0 +HEAD_HOME = 2 + +#: The challenge racers, 0-3. +RACERS = (0, 1, 2, 3) + +#: Nest surfaces, by racer. +NEST_SURFACES = ("SURFACE_EGG_01", "SURFACE_EGG_02", "SURFACE_EGG_03", + "SURFACE_EGG_04") + +#: A ``returnSpawnIndex`` that means "not coming back here". +NO_RETURN = 255 + +# Row states. +OK = "ok" +MISSING = "missing" +NOTE = "note" #: Nothing to do; worth knowing. +#: Playable, but not what the author meant: reported as a warning and drawn +#: as one, yet counted as ready - retail ships levels like it. +ADVISE = "advise" +UNKNOWN = "unknown" #: Cannot be checked from what is available. + +ERROR = "error" +WARNING = "warning" + +# What a row's button does; the panel maps each to an operator. +FIX_START = "START" +FIX_AI_LINE = "AI_LINE" +FIX_AI_TAG = "AI_TAG" +FIX_AI_LINK = "AI_LINK" +FIX_CHESTS = "CHESTS" +FIX_FLAGS = "FLAGS" +FIX_HEAD_FOR = "HEAD_FOR" +FIX_EGG_CREATOR = "EGG_CREATOR" +FIX_COIN_CREATOR = "COIN_CREATOR" +FIX_BALLOON = "BALLOON" +FIX_EXIT = "EXIT" +FIX_CHECKPOINT = "CHECKPOINT" + + +class Row: + """One requirement: what it is, whether it is met, and why it matters.""" + + __slots__ = ("rid", "label", "status", "detail", "problem", "severity", + "fix", "objects", "reported") + + def __init__(self, rid: str, label: str, status: str, detail: str, + problem: str = "", severity: str = WARNING, fix: str = "", + objects: Iterable[int] = (), reported: bool = True): + self.rid = rid + #: Short, with the count where there is one: ``"Treasure chests 3/4"``. + self.label = label + self.status = status + #: One or two sentences: why the level needs it. + self.detail = detail + #: What validation says when it is missing; the label and detail if + #: left empty. + self.problem = problem + self.severity = severity + self.fix = fix + #: Document positions of the objects the row is about. + self.objects = list(objects) + #: False where another check already reports the same thing - the + #: start grid has its own, more detailed, validation. + self.reported = reported + + @property + def ok(self) -> bool: + return self.status == OK + + @property + def message(self) -> str: + return self.problem or "%s: %s" % (self.label, self.detail) + + def __repr__(self): + return "Row(%r, %r)" % (self.rid, self.status) + + +# --------------------------------------------------------------------------- +# Reading the map +# --------------------------------------------------------------------------- + +class _Map: + """The object map, indexed once for every rule.""" + + def __init__(self, object_map): + self.objects = list(object_map.objects) + self.by_id: Dict[str, List[int]] = {} + for position, obj in enumerate(self.objects): + self.by_id.setdefault(obj.object_id, []).append(position) + + def positions(self, object_id: str) -> List[int]: + return self.by_id.get(object_id, []) + + def count(self, object_id: str) -> int: + return len(self.positions(object_id)) + + def field(self, position: int, name: str, default=None): + return self.objects[position].fields.get(name, default) + + def values(self, object_id: str, name: str) -> Dict[object, List[int]]: + """``{value: [positions]}`` of one field across one type.""" + found: Dict[object, List[int]] = {} + for position in self.positions(object_id): + found.setdefault(self.field(position, name), []).append(position) + return found + + def starts(self, entrance: int = 0) -> Set[int]: + return {self.field(p, "racerIndex") for p in self.positions(SETUPPOINT) + if self.field(p, "entranceID", 0) == entrance} + + def entrances(self) -> Set[int]: + return {self.field(p, "entranceID", 0) for p in self.positions(SETUPPOINT)} + + def linked_nodes(self) -> int: + """AI nodes holding at least one link.""" + return sum(1 for p in self.positions(AINODE) if self.is_linked(p)) + + def is_linked(self, position: int) -> bool: + return any(a != ai_graph.NO_NEIGHBOUR + for a in (self.field(position, "adjacent") or [])) + + def where(self, position: int): + return tuple(self.objects[position].translation or (0.0, 0.0, 0.0)) + + +def _nodes(ids: Sequence[object]) -> str: + """``[14, 20]`` -> ``"nodes 14 and 20"``.""" + names = [str(v) for v in ids] + if not names: + return "no node" + if len(names) == 1: + return "node %s" % names[0] + shown = names[:6] + (["%d more" % (len(names) - 6)] if len(names) > 6 else []) + return "nodes %s and %s" % (", ".join(shown[:-1]), shown[-1]) + + +def _players(values: Sequence[int]) -> str: + """``[0, 2]`` -> ``"racers 0 and 2"``.""" + names = [str(v) for v in values] + if not names: + return "no racer" + if len(names) == 1: + return "racer %s" % names[0] + return "racers %s and %s" % (", ".join(names[:-1]), names[-1]) + + +# --------------------------------------------------------------------------- +# The rules +# --------------------------------------------------------------------------- + +def _start_row(found: _Map, key: str) -> Row: + count = level_types.spawn_count(key) + present = found.starts(0) + missing = [i for i in range(count) if i not in present] + if key == level_types.BOSS: + label = "Start: player and boss" + detail = ("racerIndex 0 is the player, 1 the boss, both at entrance 0. " + "Generate Start Grid places the pair side by side.") + else: + label = "Start ring %d/%d" % (count - len(missing), count) + detail = ("Four racers start at entrance 0, racerIndex 0-3. Generate " + "Start Grid places a ring facing the centre.") + return Row("start", label, MISSING if missing else OK, detail, + severity=ERROR, fix=FIX_START, reported=False) + + +def _graph_rows(found: _Map, key: str) -> List[Row]: + nodes = found.count(AINODE) + linked = found.linked_nodes() + rows = [Row( + "ai_graph", + "AI node graph: %d nodes" % nodes if nodes else "AI node graph", + OK if linked >= 2 else MISSING, + "Battle and banana bots steer only along this graph. Draw a curve " + "through the arena, select it, then sample it with AI Line From " + "Curve.", + problem=("No connected AI node graph: the AI racers will not move. " + "%s bots steer only along the AI node graph; draw one with " + "AI Node Graph > AI Line From Curve." + % ("Battle" if key == level_types.BATTLE else "Banana")), + severity=ERROR, fix=FIX_AI_LINE, + )] + if linked < 2: + return rows + + classes = found.values(AINODE, "unk8") + tagged = len(classes.get(CLASS_BALLOON, [])) + rows.append(Row( + "balloon_nodes", "Weapon nodes: %d" % tagged, OK if tagged else MISSING, + "Nodes with destination class 1 are where a bot goes for a weapon. " + "Tag Destinations marks the node nearest each balloon.", + problem=("No AI node has destination class 1, so the bots never go " + "for a weapon balloon. Tag Destinations in AI Node Graph " + "marks the node nearest each balloon."), + fix=FIX_AI_TAG if found.count(BALLOON) else FIX_BALLOON, + )) + rows += _weapon_node_rows(found, classes.get(CLASS_BALLOON, [])) + if key == level_types.BANANAS: + missing = [r for r in RACERS if not classes.get(CLASS_BANK + r)] + rows.append(Row( + "bank_nodes", "Bank nodes %d/4" % (4 - len(missing)), + MISSING if missing else OK, + "A node with destination class 4 + racer, by that racer's chest, " + "is where a bot carrying two or more bananas drives to bank them.", + problem=("No AI node has destination class %s, so %s never drive " + "home to bank. Tag Destinations marks the node nearest " + "each chest." % ( + " or ".join(str(CLASS_BANK + r) for r in missing), + _players(missing).replace("racer", "bot"))), + fix=FIX_AI_TAG if found.count(CHEST) else FIX_CHESTS, + )) + return rows + + +def _weapon_node_rows(found: _Map, weapons: Sequence[int]) -> List[Row]: + """Whether each weapon node has a balloon on it, and a link to the graph. + + Bots never look at a balloon: they drive to the class-1 node and turn + for the next node 50 units out (``racer.c:682``), so a balloon off its + node is driven past and a node with no balloon is a wasted trip. That is + advice rather than a fault - Smokey Castle ships two class-1 nodes 215 + and 762 units from any balloon - so it is never an error, and the reach + accepted is :data:`ai_graph.WEAPON_REACH_CHECK`, which retail's + placements pass, rather than the 50 the overlay draws. + """ + rows = [] + balloons = [found.where(p) for p in found.positions(BALLOON)] + if weapons and balloons: + far = [p for p in weapons + if min(math.dist(found.where(p), b) for b in balloons) + > ai_graph.WEAPON_REACH_CHECK] + ids = [found.field(p, "nodeID", "?") for p in far] + # Shown only while some node is off its balloon: a met row hides it. + detail = ("Bots never look at balloons, only at weapon nodes. %s %s no " + "balloon within %d units, so a bot drives there for nothing: " + "put a balloon on it, or set its class to 0." + % (_nodes(ids).capitalize(), "has" if len(far) == 1 else "have", + ai_graph.WEAPON_REACH_CHECK)) + rows.append(Row( + "weapon_reach", "Weapon nodes on a balloon %d/%d" + % (len(weapons) - len(far), len(weapons)), + ADVISE if far else OK, detail, + problem=("Weapon %s %s no weapon balloon within %d units: a bot " + "without a weapon drives there, finds nothing to take and " + "goes on to the next. Put a balloon on it, or set its " + "destination class to 0." + % (_nodes(ids), "has" if len(far) == 1 else "have", + ai_graph.WEAPON_REACH_CHECK)), + objects=far, + )) + + loose = [p for p in weapons if not found.is_linked(p)] + if loose: + ids = [found.field(p, "nodeID", "?") for p in loose] + rows.append(Row( + "weapon_links", "Weapon nodes linked %d/%d" + % (len(weapons) - len(loose), len(weapons)), MISSING, + "A weapon node with no link is off the graph: the pathfinder " + "never reaches it, so no bot comes for its balloon.", + problem=("Weapon %s %s not linked to the AI graph, so no bot ever " + "comes for %s balloon. Link Weapon Nodes in AI Node Graph " + "joins each to the nodes nearest it." + % (_nodes(ids), "is" if len(loose) == 1 else "are", + "its" if len(loose) == 1 else "their")), + fix=FIX_AI_LINK, objects=loose, + )) + return rows + + +def _balloon_row(found: _Map, key: str) -> Row: + count = found.count(BALLOON) + if key == level_types.EGGS: + detail = ("A bot with nothing else to do fetches a weapon. With no " + "balloon to find, it searches forever and the game freezes.") + problem = ("No weapon balloon: an egg bot with nothing else to do " + "searches for one forever and the game freezes. Place at " + "least one.") + severity = ERROR + elif key == level_types.BATTLE: + detail = ("Racers lose their bananas to weapons, and a racer left with " + "none is out. Without balloons nobody can be knocked out.") + problem = ("No weapon balloon: nobody can be hit, so nobody runs out " + "of bananas and the battle never ends.") + severity = WARNING + else: + detail = "Weapons let racers knock bananas out of each other." + problem = "No weapon balloon: the racers cannot attack each other." + severity = WARNING + return Row("balloons", "Weapon balloons: %d" % count, + OK if count else MISSING, detail, problem=problem, + severity=severity, fix=FIX_BALLOON) + + +def owner(object_id: str, value) -> Optional[int]: + """The racer an object's racer field names, as the game reads it. + + A chest stores ``playerID - 1`` masked to two bits + (``object_functions.c:4204``), so 1-4 are racers 0-3 and a 0 is racer 3, + not racer 0. A flag's index and a Head-For Point's are taken as they are. + """ + if not isinstance(value, int): + return None + if object_id == CHEST: + return (value - 1) & 3 + return value + + +def _per_racer(found: _Map, object_id: str, field: str): + """``(present, missing, positions)`` of one per-racer type, by racer 0-3.""" + owners = {owner(object_id, found.field(p, field)) + for p in found.positions(object_id)} + present = [r for r in RACERS if r in owners] + missing = [r for r in RACERS if r not in present] + return present, missing, found.positions(object_id) + + +def _flag_row(found: _Map, over: str) -> Row: + present, missing, where = _per_racer(found, FLAG, "playerIndex") + return Row( + "flags", "Racer flags %d/4" % len(present), MISSING if missing else OK, + "A Character Flag shows a racer's portrait over their %s, so players " + "can tell whose is whose." % over, + problem="No Character Flag for %s: nothing shows whose %s is whose." + % (_players(missing), over), + fix=FIX_FLAGS, objects=where, + ) + + +def _battle(found: _Map) -> List[Row]: + return [_start_row(found, level_types.BATTLE)] + \ + _graph_rows(found, level_types.BATTLE) + \ + [_balloon_row(found, level_types.BATTLE)] + + +def _bananas(found: _Map) -> List[Row]: + present, missing, where = _per_racer(found, CHEST, "playerID") + rows = [_start_row(found, level_types.BANANAS)] + rows.append(Row( + "chests", "Treasure chests %d/4" % len(present), + MISSING if missing else OK, + "Each racer banks bananas at their own chest - playerID is the racer " + "plus 1 - from within 225 units. Ten banked wins.", + problem=("No treasure chest for %s: they can never bank a banana, so " + "they can never win. Each racer needs a chest whose playerID " + "is its racerIndex + 1." % _players(missing)), + severity=ERROR, fix=FIX_CHESTS, objects=where, + )) + creators = found.count(COIN_CREATOR) + loose = found.count(COIN) + rows.append(Row( + "bananas", "Banana creators: %d" % creators, + OK if creators else MISSING, + "A Coin Creator holds a banana and makes another 20 seconds after it " + "is taken. Loose bananas never come back.", + problem=("No banana to collect: place Coin Creators, which make a new " + "banana 20 seconds after one is taken." if not loose else + "No Coin Creator: the %d loose banana(s) never come back, so " + "the challenge can run dry before anyone banks ten." % loose), + severity=ERROR if not loose else WARNING, fix=FIX_COIN_CREATOR, + )) + rows += _graph_rows(found, level_types.BANANAS) + rows.append(_flag_row(found, "chest")) + rows.append(_balloon_row(found, level_types.BANANAS)) + return rows + + +def _eggs(found: _Map, surfaces: Optional[Set[str]]) -> List[Row]: + rows = [_start_row(found, level_types.EGGS)] + creators = found.count(EGG_CREATOR) + rows.append(Row( + "egg_creator", "Egg creators: %d" % creators, OK if creators else MISSING, + "An Egg Creator keeps one egg on it and makes a new one whenever it " + "is taken. Retail's Fire Mountain has three in the middle.", + problem="No Egg Creator: no egg ever appears.", + severity=ERROR, fix=FIX_EGG_CREATOR, + )) + + if surfaces is None: + rows.append(Row( + "nests", "Nest floors", UNKNOWN, + "A nest is ground, not an object: racer N's eggs count on faces " + "with surface Egg %s. Load the track geometry to check." % + "/".join(str(r + 1) for r in RACERS), + reported=False, + )) + else: + missing = [r for r in RACERS if NEST_SURFACES[r] not in surfaces] + rows.append(Row( + "nests", "Nest floors %d/4" % (4 - len(missing)), + MISSING if missing else OK, + "A nest is ground, not an object: an egg dropped on surface " + "SURFACE_EGG_0N counts for racer N-1, and anywhere else it breaks. " + "Set it in Geometry > Surface.", + problem=("No geometry uses %s, so %s have no nest: every egg they " + "drop breaks and goes back to the creator. Set the nest " + "floors' surface in Geometry > Surface." + % (", ".join(NEST_SURFACES[r] for r in missing), + _players(missing))), + severity=ERROR, + )) + + values = found.values(HEAD_FOR, "unk8") + drops = [p for p in values.get(HEAD_DROP, [])] + homes = [p for p in values.get(HEAD_HOME, [])] + owners_drop = {found.field(p, "unk9") for p in drops} + owners_home = {found.field(p, "unk9") for p in homes} + missing = [r for r in RACERS if r not in owners_drop or r not in owners_home] + status = OK if not missing else MISSING + if not drops: + problem = ("No Head-For Point of kind 0: a bot carrying an egg looks " + "for one to drop it at, never finds it, and the game " + "freezes. Add Head-For Points places a pair at each nest.") + severity = ERROR + else: + problem = ("%s %s no Head-For Point pair (kind 0 at the nest, kind 2 " + "on the way to it): their bots drop eggs at another nest " + "or stall." % (_players(missing).capitalize(), + "has" if len(missing) == 1 else "have")) + severity = WARNING + rows.append(Row( + "head_for", "Head-For Points %d/4 racers" % (4 - len(missing)), status, + "Where the bots aim: kind 0 over a nest is where they drop an egg, " + "kind 2 their way home. Racer is the nest's owner, 0-3.", + problem=problem, severity=severity, fix=FIX_HEAD_FOR, + objects=found.positions(HEAD_FOR), + )) + rows.append(_flag_row(found, "nest")) + rows.append(_balloon_row(found, level_types.EGGS)) + rows.append(Row( + "no_graph", "No AI node graph needed", NOTE, + "Egg bots steer straight at eggs, nests, Head-For Points and " + "balloons; AI nodes do nothing here.", + )) + return rows + + +def _hub(found: _Map) -> List[Row]: + rows = [] + present = found.starts(0) + rows.append(Row( + "entrance0", "Entrance 0", OK if 0 in present else MISSING, + "Where a new game and a plain warp put the player: a start position " + "with entranceID 0 and racerIndex 0. Each door back needs its own " + "entrance.", + severity=ERROR, fix=FIX_START, reported=False, + )) + + exits = found.positions(EXIT) + rows.append(Row( + "exits", "Exits: %d" % len(exits), OK if exits else MISSING, + "An Exit warps the player when they drive through it, inside its " + "radius and from its front.", + problem="No Exit: the player can never leave this hub.", + fix=FIX_EXIT, + )) + if exits: + aimless = [p for p in exits + if not level_links.linked_track(found.objects[p]) + and not found.field(p, level_links.DESTINATION_FIELD)] + rows.append(Row( + "destinations", "Exit destinations %d/%d" + % (len(exits) - len(aimless), len(exits)), + MISSING if aimless else OK, + "Each Exit names a retail level or another custom track; pick it " + "in the DKR Object panel.", + problem=("%d Exit(s) lead nowhere. Pick a destination in the DKR " + "Object panel: a retail level, or another custom track by " + "its id." % len(aimless)), + severity=ERROR, objects=aimless, + )) + entrances = found.entrances() + stranded = [] + for p in exits: + back = found.field(p, "returnSpawnIndex", 0) + if isinstance(back, int) and back != NO_RETURN and back not in entrances: + stranded.append(p) + wanted = sorted({found.field(p, "returnSpawnIndex") for p in stranded}) + rows.append(Row( + "returns", "Return entrances", MISSING if stranded else OK, + "returnSpawnIndex is the entrance of this hub the player comes " + "back to after the race; 255 means they do not come back here.", + problem=("%d Exit(s) bring the player back to entrance %s, which " + "this hub does not have: they would start at the map " + "origin. Add Entrance Point, then set its entranceID." + % (len(stranded), ", ".join(str(w) for w in wanted))), + fix=FIX_START, objects=stranded, + )) + + walkers = [label for object_id, label in ((TT, "T.T."), (TAJ, "Taj")) + if found.count(object_id)] + if walkers: + nodes = found.linked_nodes() + rows.append(Row( + "npc_graph", "AI node graph: %d nodes" % found.count(AINODE), + OK if nodes >= 2 else MISSING, + "%s walk%s the AI node graph." % ( + " and ".join(walkers), "s" if len(walkers) == 1 else ""), + problem=("%s walk%s the AI node graph, which this hub has none of: " + "draw one in AI Node Graph." % ( + " and ".join(walkers), + "s" if len(walkers) == 1 else "")), + fix=FIX_AI_LINE, + )) + if found.count(TAJ): + count = found.count(CHECKPOINT) + rows.append(Row( + "taj_course", "Taj's course: %d checkpoints" % count, + OK if count else MISSING, + "Taj's challenge races run around this hub's checkpoints.", + problem="Taj is here but the hub has no checkpoints: his challenge " + "races have no course.", + fix=FIX_CHECKPOINT, + )) + return rows + + +def _boss(found: _Map, boss: Optional[str]) -> List[Row]: + rows = [_start_row(found, level_types.BOSS)] + known = {b[0] for b in level_types.BOSSES} + if boss is not None: + chosen = boss in known + rows.append(Row( + "boss", "Boss: %s" % level_types.boss_label(boss) if chosen else "Boss", + OK if chosen else MISSING, + "Chosen above. The race is run in the boss's vehicle (%s)." + % level_types.vehicle_name(level_types.boss_vehicle(boss)).lower(), + problem="No boss chosen: the header's boss-race-id decides who " + "the player races and in what.", + severity=ERROR, + )) + count = found.count(CHECKPOINT) + rows.append(Row( + "checkpoints", "Checkpoints: %d" % count, + OK if count >= 3 else MISSING, + "The boss steers along the checkpoints and laps count through them, " + "as in a race. AI Racers shows the line it drives.", + problem=("No checkpoints: the boss has no line to drive and no lap " + "ever counts." if not count else + "Only %d checkpoint(s): with fewer than three the boss's line " + "folds back on itself." % count), + severity=ERROR if not count else WARNING, fix=FIX_CHECKPOINT, + )) + return rows + + +# --------------------------------------------------------------------------- +# Entry points +# --------------------------------------------------------------------------- + +#: The level types the checklist speaks for. +KEYS = (level_types.BATTLE, level_types.BANANAS, level_types.EGGS, + level_types.HUB, level_types.BOSS) + + +def applies(key: Optional[str]) -> bool: + return key in KEYS + + +def rows(object_map, key: Optional[str], boss: Optional[str] = None, + surfaces: Optional[Iterable[str]] = None) -> List[Row]: + """The checklist for one level type; empty for a race or a special. + + ``boss`` is the scene's boss, or ``None`` when it is not known. + ``surfaces`` names the ``SurfaceType`` members the track geometry uses, or + ``None`` when there is no geometry to look at. + """ + found = _Map(object_map) + surface_set = set(surfaces) if surfaces is not None else None + if key == level_types.BATTLE: + return _battle(found) + if key == level_types.BANANAS: + return _bananas(found) + if key == level_types.EGGS: + return _eggs(found, surface_set) + if key == level_types.HUB: + return _hub(found) + if key == level_types.BOSS: + return _boss(found, boss) + return [] + + +def issues(object_map, key: Optional[str], boss: Optional[str] = None, + surfaces: Optional[Iterable[str]] = None): + """``(severity, message, object_id, positions)`` per unmet requirement.""" + found = [] + for row in rows(object_map, key, boss, surfaces): + if row.status not in (MISSING, ADVISE) or not row.reported: + continue + severity = WARNING if row.status == ADVISE else row.severity + found.append((severity, row.message, "", row.objects)) + return found + + +def summary(checklist: Sequence[Row]) -> str: + """``"5 of 7 ready"`` for the panel header.""" + counted = [r for r in checklist if r.status in (OK, MISSING, ADVISE)] + done = sum(1 for r in counted if r.status != MISSING) + return "%d of %d ready" % (done, len(counted)) + + +def missing_racers(object_map, object_id: str, field: str, + match: Optional[Dict[str, object]] = None) -> List[int]: + """Racers 0-3 with no object of a type yet, for the Add buttons. + + ``match`` narrows the objects counted, e.g. Head-For Points of one kind. + """ + owned = set() + for obj in object_map.objects: + if obj.object_id != object_id: + continue + if match and any(obj.fields.get(k) != v for k, v in match.items()): + continue + owned.add(owner(object_id, obj.fields.get(field))) + return [r for r in RACERS if r not in owned] diff --git a/tools/blender/track_lab/object_map_encoder.py b/tools/blender/track_lab/object_map_encoder.py index 82958736..bfa456b9 100644 --- a/tools/blender/track_lab/object_map_encoder.py +++ b/tools/blender/track_lab/object_map_encoder.py @@ -125,6 +125,20 @@ def encode(self, object_map: ObjectMap) -> bytes: blob.extend(b"\x00" * (BLOB_ALIGNMENT - remainder)) return bytes(blob) + def entry_offsets(self, object_map: ObjectMap) -> List[int]: + """Where each object's entry starts in the inflated payload. + + Offsets count from the start of :meth:`encode`'s result, so the + 16-byte header is included - the same buffer the game inflates the map + into. A field's byte is its entry's offset plus the field's own. + """ + offsets = [] + position = HEADER_SIZE + for obj in object_map.objects: + offsets.append(position) + position += self.entry_size(obj) + return offsets + def pack(self, object_map: ObjectMap, level: int = 9) -> bytes: """The payload wrapped in the container the asset table stores.""" blob = self.encode(object_map) @@ -149,13 +163,17 @@ def object_index(self, object_id: str) -> int: ) return index + def entry_size(self, obj) -> int: + """The bytes one object's entry takes: its type's struct size.""" + entry = self.catalog.raw.get("objects", {}).get(obj.object_id, {}) + return entry.get("entry_size", COMMON_SIZE) + def encode_entry(self, obj) -> bytes: object_type = self.catalog.get(obj.object_id) if object_type is None: raise EncodeError("%s is not in the catalogue" % obj.object_id) - entry = self.catalog.raw.get("objects", {}).get(obj.object_id, {}) - size = entry.get("entry_size", COMMON_SIZE) + size = self.entry_size(obj) if not COMMON_SIZE <= size <= MAX_ENTRY_SIZE: raise EncodeError( "%s encodes to %d bytes; the game walks entries with a 6-bit " diff --git a/tools/blender/track_lab/operators/ai.py b/tools/blender/track_lab/operators/ai.py index 7ca1d352..edca7ef8 100644 --- a/tools/blender/track_lab/operators/ai.py +++ b/tools/blender/track_lab/operators/ai.py @@ -1,9 +1,21 @@ -"""Turn a curve into an AI node graph. +"""The AI node graph in Blender: building it, editing it and drawing it. The author draws the line they want the AI to follow; this samples it into ``ASSET_OBJECT_AINODE`` objects with the adjacency already wired. The graph rules live in :mod:`track_lab.ai_graph`; this file is the Blender end of it - reading a curve's shape, and writing the nodes back into the scene. + +It also holds the hand tools - linking and unlinking two nodes, the weapon +node a balloon gets when it is placed in Battle or Bananas - and the viewport +overlay, whose geometry :mod:`track_lab.ai_graph_view` works out. + +**Weapon nodes.** Arena bots never look at a balloon; they drive to AI nodes +of destination class 1 (see :mod:`track_lab.ai_graph`). So a balloon placed +in Battle or Bananas gets a class-1 node on it, *parented* to it so moving +the balloon moves the node. The parent inverse is the balloon's matrix at the +time, so the node's own location stays its world position: export reads +``matrix_world`` either way, and if the balloon is deleted the node stays +where it was instead of jumping to the origin. """ from __future__ import annotations @@ -13,10 +25,13 @@ import bpy from bpy.props import BoolProperty, EnumProperty, FloatProperty, IntProperty -from .. import ai_graph, catalog as catalog_module, prefs, scene +from .. import (ai_graph, ai_graph_view, catalog as catalog_module, level_types, + mode_checklist, prefs, scene) from ..gltf_io import MapObject AINODE = "ASSET_OBJECT_AINODE" +BALLOON = level_types.WEAPON_BALLOON +WEAPON_NODE_NAME = "Weapon Node · %s" def _curve_points(depsgraph, curve_object): @@ -54,10 +69,18 @@ def _is_closed(curve_object): return any(spline.use_cyclic_u for spline in curve_object.data.splines) -def _write_nodes(context, graph, catalog, elevation, unk8): - """Replace the scene's AI nodes with the ones this graph describes.""" +def _write_nodes(context, graph, catalog, slots=None, keep=(), extras=None): + """Replace the scene's AI nodes with the ones this graph describes. + + Each node keeps its own elevation and destination class, and ``slots`` + maps a node id to the object map it came from, so rewriting the graph to + splice a branch on does not flatten what the author set on the others. + ``keep`` are nodes left in place, and ``extras`` maps a node id to the + ``(parent, name)`` it had, so a weapon node stays on its balloon. + """ + kept = {id(obj) for obj in keep} for existing in list(scene.iter_dkr_objects(context)): - if str(existing.get(scene.PROP_ID)) == AINODE: + if str(existing.get(scene.PROP_ID)) == AINODE and id(existing) not in kept: bpy.data.objects.remove(existing, do_unlink=True) object_type = catalog.get(AINODE) @@ -69,19 +92,24 @@ def _write_nodes(context, graph, catalog, elevation, unk8): fields = object_type.fresh_fields() fields["nodeID"] = node.node_id fields["adjacent"] = node.adjacent - fields["elevation"] = int(elevation) + fields["elevation"] = int(node.elevation) if "unk8" in fields: - fields["unk8"] = int(unk8) + fields["unk8"] = int(node.destination) placed = MapObject( object_id=AINODE, name=object_type.node_name, translation=list(node.position), fields=fields, ) - empty = scene.create_empty(context, placed, catalog, root, tree, - context.scene.dkr.slot) + slot = (slots or {}).get(node.node_id, context.scene.dkr.slot) + empty = scene.create_empty(context, placed, catalog, root, tree, slot) empty["dkr_order"] = order order += 1 + parent, name = (extras or {}).get(node.node_id, (None, None)) + if name: + empty.name = name + if parent is not None: + attach(empty, parent) created.append(empty) return created @@ -134,8 +162,7 @@ class DKR_OT_ai_from_curve(bpy.types.Operator): @classmethod def poll(cls, context): - obj = context.active_object - return obj is not None and obj.type == "CURVE" + return _curve_selected(cls, context) def execute(self, context): curve_object = context.active_object @@ -153,7 +180,8 @@ def execute(self, context): try: catalog = catalog_module.load() graph = ai_graph.build_from_path( - points, self.spacing, closed=closed, elevation=self.elevation + points, self.spacing, closed=closed, elevation=self.elevation, + destination=self.unk8, ) except ai_graph.AiGraphError as error: self.report({"ERROR"}, str(error)) @@ -163,11 +191,13 @@ def execute(self, context): self.report({"ERROR"}, "could not build the graph: %s" % error) return {"CANCELLED"} + # A balloon's weapon node belongs to the balloon, not to the line + # being replaced: it stays, takes an id after the new nodes, and is + # linked to them - which is what lets an author place the balloons + # first and draw the graph after. + kept = [o for o in node_empties(context) if balloon_of(o) is not None] if not self.replace: - existing = sum( - 1 for o in scene.iter_dkr_objects(context) - if str(o.get(scene.PROP_ID)) == AINODE - ) + existing = len(node_empties(context)) - len(kept) if existing: self.report( {"ERROR"}, @@ -175,13 +205,24 @@ def execute(self, context): "Use Replace, or clear them first" % existing, ) return {"CANCELLED"} + if len(graph.nodes) + len(kept) > ai_graph.MAX_NODES: + self.report({"ERROR"}, "%d nodes plus the %d weapon nodes on balloons " + "is over the %d the game keeps; increase the spacing" + % (len(graph.nodes), len(kept), ai_graph.MAX_NODES)) + return {"CANCELLED"} - created = _write_nodes(context, graph, catalog, self.elevation, self.unk8) - self.report( - {"INFO"}, - "%d AI nodes from %s (%s)" - % (len(created), curve_object.name, "loop" if closed else "open chain"), - ) + created = _write_nodes(context, graph, catalog, keep=kept) + message = "%d AI nodes from %s (%s)" % ( + len(created), curve_object.name, "loop" if closed else "open chain") + if kept: + context.view_layer.update() + for offset, node in enumerate(kept): + node["nodeID"] = len(graph.nodes) + offset + node["adjacent"] = [ai_graph.NO_NEIGHBOUR] * ai_graph.MAX_NEIGHBOURS + joined = sum(1 for node in kept if auto_link(context, node)) + message += "; %d of %d weapon node(s) on balloons linked to it" % ( + joined, len(kept)) + self.report({"INFO"}, message) return {"FINISHED"} @@ -214,12 +255,11 @@ class DKR_OT_ai_add_branch(bpy.types.Operator): @classmethod def poll(cls, context): - obj = context.active_object - return obj is not None and obj.type == "CURVE" + return _curve_selected(cls, context) def execute(self, context): catalog = catalog_module.load() - graph, empties = _read_existing_graph(context) + graph, empties, slots, extras = _read_existing_graph(context) if graph is None: self.report({"ERROR"}, "no AI nodes in the scene to branch from") return {"CANCELLED"} @@ -231,37 +271,56 @@ def execute(self, context): ] try: added = ai_graph.attach_branch( - graph, points, self.spacing, elevation=self.elevation + graph, points, self.spacing, elevation=self.elevation, + destination=self.unk8, ) except ai_graph.AiGraphError as error: self.report({"ERROR"}, str(error)) return {"CANCELLED"} - # Not 0 unconditionally: a branch written with destination class 0 can - # never be a target the pathfinder is asked for, which quietly made - # every branch this operator produced unreachable as a destination. - _write_nodes(context, graph, catalog, self.elevation, self.unk8) + # The branch's nodes take this operator's elevation and destination + # class; every node already there keeps its own. Writing these two on + # the whole graph used to flatten every node's class to the branch's. + _write_nodes(context, graph, catalog, slots, extras=extras) self.report({"INFO"}, "added %d branch node(s)" % len(added)) return {"FINISHED"} +def _curve_selected(cls, context): + obj = context.active_object + if obj is None or obj.type != "CURVE": + cls.poll_message_set( + "Select the curve to sample first: draw it through the arena " + "(Add > Curve), then click this") + return False + return True + + def _read_existing_graph(context): - """Rebuild an :class:`AiGraph` from the AI nodes already in the scene.""" - empties = [ - o for o in scene.iter_dkr_objects(context) - if str(o.get(scene.PROP_ID)) == AINODE - ] + """Rebuild an :class:`AiGraph` from the AI nodes already in the scene. + + Also ``{node id: slot}`` and ``{node id: (parent, name)}`` for + :func:`_write_nodes`, keyed by the ids the rebuilt graph gave them, so a + weapon node comes back on its balloon under its own name. + """ + empties = node_empties(context) if not empties: - return None, [] + return None, [], {}, {} empties.sort(key=lambda o: int(o.get("nodeID", 0))) graph = ai_graph.AiGraph() index_of = {} + slots = {} + extras = {} for empty in empties: node = graph.add( tuple(scene.to_map(empty.matrix_world.translation)), elevation=int(empty.get("elevation", 0)), + destination=int(empty.get("unk8", 0)), ) + slots[node.node_id] = scene.slot_of(empty) + parent = empty.parent if not is_node(empty.parent) else None + extras[node.node_id] = (parent, empty.name) index_of[int(empty.get("nodeID", node.node_id))] = node for empty in empties: @@ -272,10 +331,681 @@ def _read_existing_graph(context): other = index_of.get(int(neighbour_id)) if other is not None and graph.can_link(node, other): graph.link(node, other) - return graph, empties + return graph, empties, slots, extras + + +class DKR_OT_ai_tag_destinations(bpy.types.Operator): + """Give the arena AI its targets: the AI node nearest each weapon balloon + becomes destination class 1, and in Bananas the node nearest each racer's + chest becomes 4 + that racer. Bots drive to those nodes to fetch a weapon + and to bank. A node already given a class keeps it""" + + bl_idname = "dkr.ai_tag_destinations" + bl_label = "Tag Destinations" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not any(str(o.get(scene.PROP_ID)) == AINODE + for o in scene.iter_dkr_objects(context)): + cls.poll_message_set("There are no AI nodes to tag yet: sample a " + "curve with AI Line From Curve first") + return False + return True + + def execute(self, context): + context.view_layer.update() + objects = scene.iter_dkr_objects(context) + bananas = level_types.current_key(context.scene.dkr) == level_types.BANANAS + nodes = [o for o in objects if str(o.get(scene.PROP_ID)) == AINODE] + balloons = [o for o in objects + if str(o.get(scene.PROP_ID)) == level_types.WEAPON_BALLOON] + chests = [] + if bananas: + chests = [(int(o.get("playerID", 1)) - 1, o) for o in objects + if str(o.get(scene.PROP_ID)) == mode_checklist.CHEST] + if not balloons and not chests: + self.report({"WARNING"}, "no weapon balloons%s to tag nodes for" + % (" or chests" if bananas else "")) + return {"CANCELLED"} + tags = ai_graph.destination_tags( + [tuple(n.matrix_world.translation) for n in nodes], + [tuple(b.matrix_world.translation) for b in balloons], + [(racer, tuple(c.matrix_world.translation)) for racer, c in chests], + [int(n.get("unk8", 0)) for n in nodes], + ) + for index, value in tags: + nodes[index]["unk8"] = value + weapons = sum(1 for _index, value in tags + if value == mode_checklist.CLASS_BALLOON) + if tags: + message = "tagged %d weapon node(s)" % weapons + if bananas: + message += " and %d bank node(s)" % (len(tags) - weapons) + else: + message = "every balloon%s already has its node" % ( + " and chest" if chests else "") + + # Tagged whatever the distance, as before; but a bot turns for its + # next node 50 units out, so a balloon further than that from the + # node it was given is driven past. + far = ai_graph.balloons_off_their_node( + [tuple(n.matrix_world.translation) for n in nodes], + [tuple(b.matrix_world.translation) for b in balloons]) + if far: + named = ["%s (%d units)" % (balloons[index].name, distance) + for index, distance in far[:4]] + if len(far) > 4: + named.append("%d more" % (len(far) - 4)) + if len(named) > 1: + named = "%s and %s are" % (", ".join(named[:-1]), named[-1]) + else: + named = "%s is" % named[0] + self.report({"WARNING"}, "%s, but %s more than %d units from the " + "nearest node: the bot will reach the node without " + "touching the balloon. Put a node on it - Auto Weapon " + "Node in Place does that for new balloons" + % (message, named, ai_graph.WEAPON_REACH)) + else: + self.report({"INFO"}, message) + return {"FINISHED"} + + +# --------------------------------------------------------------------------- +# Weapon nodes and links by hand +# --------------------------------------------------------------------------- + +def is_node(obj) -> bool: + return obj is not None and str(obj.get(scene.PROP_ID, "")) == AINODE + + +def is_balloon(obj) -> bool: + return obj is not None and str(obj.get(scene.PROP_ID, "")) == BALLOON + + +def node_empties(context): + return [o for o in scene.iter_dkr_objects(context) if is_node(o)] + + +def balloon_of(node): + """The weapon balloon a node is parented to, or ``None``.""" + parent = node.parent if node is not None else None + return parent if is_balloon(parent) else None + + +def weapon_nodes_of(balloon): + """The AI nodes parented to a balloon.""" + return [child for child in balloon.children if is_node(child)] + + +def balloon_label(balloon) -> str: + """``"Red Balloon"``, from the colour preset its fields match.""" + chosen = level_types.preset_for(BALLOON, balloon) + return chosen.label if chosen else "Weapon Balloon" + + +def node_id(obj): + value = obj.get("nodeID") if obj is not None else None + return value if isinstance(value, int) else None + + +def slots_of(obj): + value = obj.get("adjacent", []) + return [int(v) for v in (value.to_list() if hasattr(value, "to_list") else value)] + + +def neighbours(obj, by_id=None): + """The ids ``obj`` links to that name another node in the scene.""" + if by_id is None: + by_id = {node_id(o): o for o in node_empties(bpy.context)} + own = node_id(obj) + return [value for value in slots_of(obj) + if value != ai_graph.NO_NEIGHBOUR and value in by_id and value != own] + + +def attach(node, balloon): + """Parent ``node`` to ``balloon`` without moving it (see the module).""" + if balloon.parent is not None: + bpy.context.view_layer.update() + matrix = balloon.matrix_world.copy() + else: + matrix = balloon.matrix_basis.copy() + world = node.matrix_basis.copy() + node.parent = balloon + node.matrix_parent_inverse = matrix.inverted() + node.matrix_basis = world + + +def link(a, b): + """Write each node's id into the other's first free slot.""" + a_slots = ai_graph.link_slots(slots_of(a), node_id(b)) + b_slots = ai_graph.link_slots(slots_of(b), node_id(a)) + a["adjacent"], b["adjacent"] = a_slots, b_slots + + +def unlink(a, b): + a["adjacent"] = ai_graph.unlink_slots(slots_of(a), node_id(b)) + b["adjacent"] = ai_graph.unlink_slots(slots_of(b), node_id(a)) + + +def auto_link(context, node): + """Link ``node`` to the nearest nodes already in a graph; their ids. + + Only nodes holding a link count as the graph, so a weapon node placed + before any graph exists stays unlinked rather than pairing up with + another loose one. Straight-line distance only: see + :data:`ai_graph.AUTO_LINK_RANGE`. + """ + by_id = {node_id(o): o for o in node_empties(context) if node_id(o) is not None} + own = slots_of(node) + candidates = [] + for other_id, other in by_id.items(): + if other is node or other_id in own or not neighbours(other, by_id): + continue + candidates.append((other_id, tuple(other.matrix_world.translation), + ai_graph.free_slots(slots_of(other)))) + room = ai_graph.free_slots(own) + chosen = ai_graph.link_targets(tuple(node.matrix_world.translation), candidates, + count=min(room, ai_graph.AUTO_LINK_COUNT)) + for other_id in chosen: + link(node, by_id[other_id]) + return chosen + + +def add_weapon_node(context, balloon): + """Put a class-1 AI node on a balloon and link it. + + ``(node, linked ids)``, or ``(None, reason)`` when the track has no node + id left under the 128 the game keeps. + """ + from .edit import add_object # noqa: PLC0415 - edit imports this module + + object_type = catalog_module.load().get(AINODE) + context.view_layer.update() + node = add_object(context, object_type, balloon.matrix_world.translation.copy(), + {"unk8": ai_graph.CLASS_WEAPON}, + slot=level_types.tab_of(object_type)) + if node_id(node) is None or node_id(node) >= ai_graph.MAX_NODES: + bpy.data.objects.remove(node, do_unlink=True) + return None, ("the track already has the %d AI nodes the game keeps" + % ai_graph.MAX_NODES) + node.name = WEAPON_NODE_NAME % balloon_label(balloon) + attach(node, balloon) + context.view_layer.update() + return node, auto_link(context, node) + + +def _two_nodes(cls, context): + """The two selected AI nodes, or ``None`` after saying why not.""" + found = [o for o in context.selected_objects if is_node(o)] + if len(found) != 2: + cls.poll_message_set( + "Select exactly two AI nodes: click one, then Shift+click the " + "other (%d selected)" % len(found)) + return None + if node_id(found[0]) is None or node_id(found[0]) == node_id(found[1]): + cls.poll_message_set("The two nodes need different nodeIDs: give each " + "its own in the DKR Object panel") + return None + return found + + +def _linked(a, b) -> bool: + return node_id(b) in slots_of(a) or node_id(a) in slots_of(b) + + +class DKR_OT_ai_link_nodes(bpy.types.Operator): + """Link the two selected AI nodes both ways, as every retail link is. + Bots drive in a straight line from one to the other, so keep walls out + of the way""" + + bl_idname = "dkr.ai_link_nodes" + bl_label = "Link Nodes" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + found = _two_nodes(cls, context) + if found is None: + return False + a, b = found + if _linked(a, b): + cls.poll_message_set("Nodes %d and %d are already linked" + % (node_id(a), node_id(b))) + return False + for obj in found: + if not ai_graph.free_slots(slots_of(obj)): + cls.poll_message_set( + "Node %d already has %d neighbours, the most the format " + "holds: unlink one first" + % (node_id(obj), ai_graph.MAX_NEIGHBOURS)) + return False + return True + + def execute(self, context): + a, b = [o for o in context.selected_objects if is_node(o)] + try: + link(a, b) + except ai_graph.AiGraphError as error: + self.report({"ERROR"}, "node %s %s" % (node_id(a), error)) + return {"CANCELLED"} + _redraw(context) + self.report({"INFO"}, "linked node %d and node %d" + % (node_id(a), node_id(b))) + return {"FINISHED"} + + +class DKR_OT_ai_unlink_nodes(bpy.types.Operator): + """Remove the link between the two selected AI nodes, from both of them""" + + bl_idname = "dkr.ai_unlink_nodes" + bl_label = "Unlink Nodes" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + found = _two_nodes(cls, context) + if found is None: + return False + if not _linked(*found): + cls.poll_message_set("Nodes %d and %d are not linked" + % (node_id(found[0]), node_id(found[1]))) + return False + return True + + def execute(self, context): + a, b = [o for o in context.selected_objects if is_node(o)] + unlink(a, b) + _redraw(context) + self.report({"INFO"}, "unlinked node %d and node %d" + % (node_id(a), node_id(b))) + return {"FINISHED"} + + +def loose_weapon_nodes(context): + """Weapon nodes with no link to another node in the scene.""" + by_id = {node_id(o): o for o in node_empties(context)} + return [o for o in node_empties(context) + if int(o.get("unk8", 0)) == ai_graph.CLASS_WEAPON + and not neighbours(o, by_id)] + + +class DKR_OT_ai_link_weapon_nodes(bpy.types.Operator): + """Link each weapon node that has no link yet to the nearest nodes of the + graph, so the bots can reach it. It goes by straight-line distance and + cannot see walls: check the new links with Show AI Graph""" + + bl_idname = "dkr.ai_link_weapon_nodes" + bl_label = "Link Weapon Nodes" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not loose_weapon_nodes(context): + cls.poll_message_set("Every weapon node is already linked") + return False + return True + + def execute(self, context): + context.view_layer.update() + joined, stranded = 0, [] + for node in loose_weapon_nodes(context): + if auto_link(context, node): + joined += 1 + else: + stranded.append(str(node_id(node))) + _redraw(context) + if stranded: + self.report({"WARNING"}, "linked %d weapon node(s); node %s found no " + "graph node within %d units with a free slot: draw the " + "graph out to it, or link it by hand with Link Nodes" + % (joined, ", ".join(stranded), ai_graph.AUTO_LINK_RANGE)) + else: + self.report({"INFO"}, "linked %d weapon node(s) to the graph" % joined) + return {"FINISHED"} if joined else {"CANCELLED"} + + +class DKR_OT_ai_select_balloon(bpy.types.Operator): + """Select the weapon balloon this node is parented to""" + + bl_idname = "dkr.ai_select_balloon" + bl_label = "Select Balloon" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if balloon_of(context.active_object) is None: + cls.poll_message_set("The active object is not a weapon node on a " + "balloon") + return False + return True + + def execute(self, context): + balloon = balloon_of(context.active_object) + for obj in context.selected_objects: + obj.select_set(False) + balloon.select_set(True) + context.view_layer.objects.active = balloon + return {"FINISHED"} + + +def _redraw(context): + for area in context.screen.areas if context.screen else (): + if area.type in ("VIEW_3D", "PROPERTIES"): + area.tag_redraw() + + +# --------------------------------------------------------------------------- +# The overlay +# --------------------------------------------------------------------------- + +LINK_WIDTH = 3.5 +#: Every line is drawn over a darker, wider copy of itself, so it reads on a +#: pale floor as well as a dark one. +OUTLINE = (0.0, 0.0, 0.0, 0.55) +OUTLINE_EXTRA = 3.0 +CIRCLE_WIDTH = 3.0 +ROUTE_WIDTH = 6.0 +ROUTE_OUTLINE = 10.0 +LABEL_SIZE = 14 + +_GRAPH = {"handle": None, "labels": None, "signature": None, "geometry": None, + "batches": None, "error": None} + + +def _plain(value): + if hasattr(value, "to_list"): + return tuple(value.to_list()) + return value + + +def graph_views(context): + """``(nodes, balloons, active id)`` as :func:`ai_graph_view.build` takes them.""" + nodes, balloons = [], [] + for obj in scene.iter_dkr_objects(context): + object_id = str(obj.get(scene.PROP_ID)) + if object_id == AINODE: + parent = balloon_of(obj) + colour = (ai_graph_view.BALLOON_COLOURS.get( + str(parent.get("balloonType")), ai_graph_view.UNKNOWN_BALLOON) + if parent is not None else None) + nodes.append(ai_graph_view.NodeView( + node_id(obj), tuple(obj.matrix_world.translation), + _int(obj.get("unk8", 0)), slots_of(obj), colour)) + elif object_id == BALLOON: + balloons.append((tuple(obj.matrix_world.translation), + ai_graph_view.BALLOON_COLOURS.get( + str(obj.get("balloonType")), + ai_graph_view.UNKNOWN_BALLOON))) + active = context.view_layer.objects.active if context.view_layer else None + return nodes, balloons, node_id(active) if is_node(active) else None + + +def _int(value): + try: + return int(value) + except (TypeError, ValueError): + return value + + +def _signature(context, settings): + items = [] + for obj in scene.iter_dkr_objects(context): + if str(obj.get(scene.PROP_ID)) not in (AINODE, BALLOON): + continue + items.append((obj.name, + tuple(round(c, 3) for c in obj.matrix_world.translation), + _plain(obj.get("nodeID")), _plain(obj.get("unk8")), + _plain(obj.get("adjacent")), _plain(obj.get("balloonType")), + obj.parent.name if obj.parent else "")) + active = context.view_layer.objects.active if context.view_layer else None + return (tuple(items), active.name if is_node(active) else "", + settings.ai_graph_colour, settings.ai_graph_show_ids) + + +def graph_geometry(context): + """The overlay's geometry for the scene now, rebuilt only when something + it depends on - a node, a link, a balloon, the active node - changed.""" + settings = context.scene.dkr + signature = _signature(context, settings) + if signature != _GRAPH["signature"]: + nodes, balloons, active = graph_views(context) + _GRAPH.update(signature=signature, batches=None, + geometry=ai_graph_view.build( + nodes, balloons, active, settings.ai_graph_colour, + settings.ai_graph_show_ids)) + return _GRAPH["geometry"] + + +_ROUND_POINT = {"shader": None, "tried": False} + +_ROUND_VERTEX = """ +void main() +{ + gl_Position = viewProjectionMatrix * vec4(pos, 1.0); + gl_PointSize = size; +} +""" + +#: A disc with a one-pixel soft edge, cut out of the square a point is. +_ROUND_FRAGMENT = """ +void main() +{ + float d = length(gl_PointCoord - vec2(0.5)) * size; + float a = clamp(size * 0.5 - d + 0.5, 0.0, 1.0); + if (a <= 0.0) { + discard; + } + fragColor = vec4(color.rgb, color.a * a); +} +""" + + +def _round_point_shader(): + """A round point shader, or ``None``. + + Blender 5.2's built-in point shaders draw squares here, and a node graph + of squares reads as a circuit diagram rather than as places on a track. + Compiled once; any failure falls back to the built-in squares. + """ + if not _ROUND_POINT["tried"]: + _ROUND_POINT["tried"] = True + try: + import gpu # noqa: PLC0415 + + info = gpu.types.GPUShaderCreateInfo() + info.push_constant("MAT4", "viewProjectionMatrix") + info.push_constant("VEC4", "color") + info.push_constant("FLOAT", "size") + info.vertex_in(0, "VEC3", "pos") + info.fragment_out(0, "VEC4", "fragColor") + info.vertex_source(_ROUND_VERTEX) + info.fragment_source(_ROUND_FRAGMENT) + _ROUND_POINT["shader"] = gpu.shader.create_from_info(info) + except Exception: # noqa: BLE001 - the squares still work + traceback.print_exc() + _ROUND_POINT["shader"] = None + return _ROUND_POINT["shader"] + + +def _to_framebuffer(colour): + """An sRGB colour as the viewport's linear framebuffer wants it. + + Blender's own shaders convert their colour on the way out; a custom one + has to be handed the converted value, or every colour comes out pale. + """ + def linear(c): + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + return tuple(linear(c) for c in colour[:3]) + (colour[3],) + + +def _build_graph_batches(found): + from gpu_extras.batch import batch_for_shader # noqa: PLC0415 + + from .race_ai import _shader # noqa: PLC0415 + + smooth = _shader("POLYLINE_SMOOTH_COLOR") + uniform = _shader("POLYLINE_UNIFORM_COLOR") + # One batch per size and colour, since the point shaders take a single + # colour: a few dozen at most, built once per change. + point = _round_point_shader() or _shader("POINT_UNIFORM_COLOR", "UNIFORM_COLOR") + batches = [] + for key, width in (("circles", CIRCLE_WIDTH), ("links", LINK_WIDTH)): + pos, colour = found[key] + if smooth is None or uniform is None or not pos: + continue + batches.append(("lines", uniform, batch_for_shader( + uniform, "LINES", {"pos": pos}), OUTLINE, width + OUTLINE_EXTRA, False)) + batches.append(("lines", smooth, batch_for_shader( + smooth, "LINES", {"pos": pos, "color": colour}), None, width, False)) + if uniform is not None and found["route"]: + route = batch_for_shader(uniform, "LINES", {"pos": found["route"]}) + batches.append(("lines", uniform, route, OUTLINE, ROUTE_OUTLINE, True)) + batches.append(("lines", uniform, route, ai_graph_view.ROUTE_COLOUR, + ROUTE_WIDTH, True)) + if point is not None: + for size, pos, colours in found["points"]: + grouped = {} + for where, colour in zip(pos, colours): + grouped.setdefault(tuple(colour), []).append(where) + for colour, where in grouped.items(): + if point is _ROUND_POINT["shader"]: + colour = _to_framebuffer(colour) + batches.append(("points", point, batch_for_shader( + point, "POINTS", {"pos": where}), colour, size, False)) + return batches + + +def _draw_graph(): + """The draw handler. Like the bot lines', it must never raise: a failure + is printed once, and again only when it changes.""" + context = bpy.context + if bpy.app.background: + return # no GPU to draw with + try: + settings = getattr(context.scene, "dkr", None) + if settings is None or not settings.show_ai_graph: + return + found = graph_geometry(context) + if not found["points"]: + return + if _GRAPH["batches"] is None: + _GRAPH["batches"] = _build_graph_batches(found) + + import gpu # noqa: PLC0415 + + region = context.region + size = (float(region.width), float(region.height)) if region else (1.0, 1.0) + view = context.region_data.perspective_matrix if context.region_data else None + gpu.state.blend_set("ALPHA") + try: + for kind, shader, batch, colour, width, on_top in _GRAPH["batches"]: + gpu.state.depth_test_set( + "NONE" if on_top or settings.ai_graph_on_top else "LESS_EQUAL") + shader.bind() + if colour is not None: + shader.uniform_float("color", colour) + if kind == "points": + if shader is _ROUND_POINT["shader"]: + if view is None: + continue + shader.uniform_float("viewProjectionMatrix", view) + shader.uniform_float("size", width) + gpu.state.program_point_size_set(True) + else: + gpu.state.program_point_size_set(False) + gpu.state.point_size_set(width) + else: + shader.uniform_float("viewportSize", size) + shader.uniform_float("lineWidth", width) + batch.draw(shader) + finally: + gpu.state.blend_set("NONE") + gpu.state.depth_test_set("NONE") + gpu.state.point_size_set(1.0) + gpu.state.program_point_size_set(False) + _GRAPH["error"] = None + except Exception: # noqa: BLE001 - a draw handler must never raise + message = traceback.format_exc() + if message != _GRAPH["error"]: + _GRAPH["error"] = message + print("Track Lab: could not draw the AI graph\n" + message) + + +def _draw_graph_labels(): + """Each node's id beside it when Show Node IDs is on, and the words under + a weapon node that has no balloon in reach.""" + context = bpy.context + if bpy.app.background: + return + try: + settings = getattr(context.scene, "dkr", None) + if settings is None or not settings.show_ai_graph: + return + region, view3d = context.region, context.region_data + if region is None or view3d is None: + return + import blf # noqa: PLC0415 + from bpy_extras.view3d_utils import location_3d_to_region_2d # noqa: PLC0415 + + found = graph_geometry(context) + font = 0 + blf.size(font, LABEL_SIZE) + blf.enable(font, blf.SHADOW) + blf.shadow(font, 3, 0.0, 0.0, 0.0, 1.0) + blf.shadow_offset(font, 1, -1) + try: + for where, text, colour in found["labels"]: + point = location_3d_to_region_2d(region, view3d, where) + if point is None: + continue + if text == ai_graph_view.NO_BALLOON: + # Centred under the node and clear of its reach circle, + # however far the view is zoomed in. + rim = location_3d_to_region_2d( + region, view3d, (where[0], where[1] - ai_graph.WEAPON_REACH, + where[2])) + drop = 30.0 + if rim is not None: + drop = max(drop, (rim - point).length + 16.0) + width, _height = blf.dimensions(font, text) + blf.position(font, point.x - width / 2.0, point.y - drop, 0.0) + else: + blf.position(font, point.x + 14.0, point.y + 8.0, 0.0) + blf.color(font, *colour) + blf.draw(font, text) + finally: + blf.disable(font, blf.SHADOW) + except Exception: # noqa: BLE001 - a draw handler must never raise + message = traceback.format_exc() + if message != _GRAPH["error"]: + _GRAPH["error"] = message + print("Track Lab: could not draw the AI node ids\n" + message) + + +def register_overlay(): + if _GRAPH["handle"] is None: + _GRAPH["handle"] = bpy.types.SpaceView3D.draw_handler_add( + _draw_graph, (), "WINDOW", "POST_VIEW") + if _GRAPH["labels"] is None: + _GRAPH["labels"] = bpy.types.SpaceView3D.draw_handler_add( + _draw_graph_labels, (), "WINDOW", "POST_PIXEL") + + +def unregister_overlay(): + for key in ("handle", "labels"): + if _GRAPH[key] is not None: + bpy.types.SpaceView3D.draw_handler_remove(_GRAPH[key], "WINDOW") + _GRAPH[key] = None + _GRAPH.update(signature=None, geometry=None, batches=None) CLASSES = ( DKR_OT_ai_from_curve, DKR_OT_ai_add_branch, + DKR_OT_ai_tag_destinations, + DKR_OT_ai_link_nodes, + DKR_OT_ai_unlink_nodes, + DKR_OT_ai_link_weapon_nodes, + DKR_OT_ai_select_balloon, ) diff --git a/tools/blender/track_lab/operators/checks.py b/tools/blender/track_lab/operators/checks.py index 7c58c4db..018761e1 100644 --- a/tools/blender/track_lab/operators/checks.py +++ b/tools/blender/track_lab/operators/checks.py @@ -48,8 +48,11 @@ def execute(self, context): try: catalog = catalog_module.load() object_map = scene.export_object_map(context, catalog, resolve_scroll=False) + from . import mode_checklist # noqa: PLC0415 + boss, surfaces = mode_checklist.validation_facts(context) report = validate.validate( object_map, catalog, level_key=key or level_types.NONE, + boss=boss, surfaces=surfaces, ) if key: report = validate.Report(list(report) + grid_issues(context, key)) diff --git a/tools/blender/track_lab/operators/edit.py b/tools/blender/track_lab/operators/edit.py index 9764940b..1ab7c8a5 100644 --- a/tools/blender/track_lab/operators/edit.py +++ b/tools/blender/track_lab/operators/edit.py @@ -5,7 +5,7 @@ import bpy from bpy.props import EnumProperty, StringProperty -from .. import catalog as catalog_module, level_types, prefs, scene +from .. import ai_graph, catalog as catalog_module, level_types, prefs, scene from ..gltf_io import MapObject from . import snap @@ -170,9 +170,11 @@ def description(cls, context, properties): object_type = None if object_type is None: return cls.__doc__ + settings = context.scene.dkr return "%s %s" % (level_types.place_description( - object_type, level_types.current_key(context.scene.dkr), + object_type, level_types.current_key(settings), level_types.preset(properties.preset) if properties.preset else None, + auto_weapon_node=settings.auto_weapon_node, ), CLICK_HINT) @classmethod @@ -317,11 +319,16 @@ def _show_status(self, context): area.tag_redraw() def _remove_last(self, context): + from .ai import weapon_nodes_of # noqa: PLC0415 + while self._placed: obj = bpy.data.objects.get(self._placed.pop()) if obj is None: continue # already deleted by hand name = obj.name + # A balloon's weapon node came with the click, so it goes with it. + for node in weapon_nodes_of(obj): + bpy.data.objects.remove(node, do_unlink=True) bpy.data.objects.remove(obj, do_unlink=True) bpy.ops.ed.undo_push(message="Remove %s" % name) self.report({"INFO"}, "removed %s" % name) @@ -344,32 +351,13 @@ def _type(self, context): def _place(self, context, object_type, location): """One object of this type at a world position; the object.""" - catalog = _catalog() object_id = object_type.object_id - fields = object_type.fresh_fields() chosen = level_types.preset(self.preset) if self.preset else None - if chosen is not None and chosen.object_id == object_id: - fields.update(chosen.fields) - else: + if chosen is None or chosen.object_id != object_id: chosen = None - _assign_free_index(context, object_id, fields) - - placed = MapObject( - object_id=object_id, - name=object_type.node_name, - translation=scene.to_map(location), - fields=fields, - ) - - root = scene.ensure_root(context) - empty = scene.create_empty(context, placed, catalog, root, - _asset_tree(context), - context.scene.dkr.slot) - empty["dkr_order"] = scene.next_order(context) - if chosen is not None: - # A label for the outliner only; the node name the export writes - # stays the type's own, in dkr_node_name. - empty.name = chosen.label.replace(" ", "") + empty = add_object(context, object_type, location, + chosen.fields if chosen else None, + label=chosen.label if chosen else "") for obj in context.selected_objects: obj.select_set(False) @@ -378,7 +366,10 @@ def _place(self, context, object_type, location): shown = chosen.label if chosen else object_type.label key = level_types.current_key(context.scene.dkr) - if empty.type == "EMPTY" and _asset_tree(context) is None: + weapon = self._weapon_node(context, empty, key) + if weapon: + self.report({"INFO"}, "%s%s" % (shown, weapon)) + elif empty.type == "EMPTY" and _asset_tree(context) is None: self.report( {"WARNING"}, "placed %s as a marker; set the decomp asset path in the addon " @@ -389,12 +380,71 @@ def _place(self, context, object_type, location): % (shown, level_types.label(key))) else: unique = UNIQUE_FIELDS.get(object_id, (None,))[0] - index = (" (%s %d)" % (unique, fields[unique]) - if unique in fields else "") + index = (" (%s %d)" % (unique, empty[unique]) + if unique and unique in empty else "") self.report({"INFO"}, "placed %s%s in the %s map" % (shown, index, scene.slot_of(empty))) return empty + @staticmethod + def _weapon_node(context, balloon, key): + """Give a balloon placed in Battle or Bananas its weapon node. + + The report's tail - ``" + weapon node (linked to 2 nodes)"`` - or + ``""`` when none was added. Bots there never look at balloons, only + at AI nodes of destination class 1 (see :mod:`..ai_graph`). + """ + if (str(balloon.get(scene.PROP_ID)) != level_types.WEAPON_BALLOON + or key not in level_types.WEAPON_NODE_KEYS + or not context.scene.dkr.auto_weapon_node): + return "" + from .ai import add_weapon_node, neighbours, node_empties # noqa: PLC0415 + + node, linked = add_weapon_node(context, balloon) + if node is None: + return " placed, but no weapon node: %s" % linked + if linked: + return " + weapon node (linked to %d node%s)" % ( + len(linked), "s" if len(linked) > 1 else "") + if not any(neighbours(n) for n in node_empties(context)): + return (" + weapon node (not linked yet: AI Line From Curve links it " + "when you draw the graph)") + return (" + weapon node (not linked: no graph node with a free slot " + "within %d units. Select it and a node, then Link Nodes)" + % ai_graph.AUTO_LINK_RANGE) + + +def add_object(context, object_type, location, fields=None, slot=None, + label=""): + """Add one object of a type at a world position, and return it. + + ``fields`` overrides the type's fresh values - a preset's colour, a + chest's racer - and a unique index the type needs is still assigned + around them. ``slot`` defaults to the Place tab's; ``label`` renames it + in the outliner only, since the node name the export writes stays the + type's own, in ``dkr_node_name``. What placing by click, the Mode + Checklist's Add buttons and scripts all share. + """ + catalog = _catalog() + object_id = object_type.object_id + values = object_type.fresh_fields() + values.update(fields or {}) + _assign_free_index(context, object_id, values) + placed = MapObject( + object_id=object_id, + name=object_type.node_name, + translation=scene.to_map(location), + fields=values, + ) + root = scene.ensure_root(context) + empty = scene.create_empty(context, placed, catalog, root, + _asset_tree(context), + slot or context.scene.dkr.slot) + empty["dkr_order"] = scene.next_order(context) + if label: + empty.name = label.replace(" ", "") + return empty + #: Blender's enum callbacks hand their strings to C without taking a reference, #: so a list built fresh on every call can be collected while the menu is still @@ -547,13 +597,17 @@ def execute(self, context): # DKR object is about to be replaced, so only a grid root is kept. existing = sorted(scene.iter_dkr_objects(context), key=lambda o: o.get("dkr_order", 1 << 30)) + position = {obj.name: index for index, obj in enumerate(existing)} records = [] for obj in existing: parent = obj.parent if parent is not None and scene.is_dkr_object(parent): - parent = None + # Rebuilt too - a weapon node's balloon - so remembered by + # place and re-pointed at its replacement below. + parent = position.get(parent.name) records.append((scene.read_object(obj, catalog), scene.slot_of(obj), - parent, obj.matrix_world.copy(), obj.get("dkr_order"))) + parent, obj.matrix_world.copy(), obj.get("dkr_order"), + obj.name)) # An object's Blender type is fixed at creation, so an Empty cannot grow # a mesh. Rebuilding is the only way to give artwork to objects that @@ -562,14 +616,21 @@ def execute(self, context): bpy.data.objects.remove(obj, do_unlink=True) rebuilt = [] - for placed, slot, parent, matrix, order in records: + for placed, slot, parent, matrix, order, name in records: obj = scene.create_empty(context, placed, catalog, root, tree, slot) - if parent is not None: + if parent is not None and not isinstance(parent, int): obj.parent = parent obj.matrix_world = matrix if order is not None: obj["dkr_order"] = order + obj.name = name rebuilt.append(obj) + context.view_layer.update() + for obj, (_placed, _slot, parent, matrix, _order, _name) in zip(rebuilt, records): + if isinstance(parent, int): + obj.parent = rebuilt[parent] + obj.matrix_parent_inverse = rebuilt[parent].matrix_world.inverted() + obj.matrix_world = matrix drawn = sum(1 for o in rebuilt if preview.PROP_PREVIEW in o) self.report( diff --git a/tools/blender/track_lab/operators/mode_checklist.py b/tools/blender/track_lab/operators/mode_checklist.py new file mode 100644 index 00000000..cdf86dd5 --- /dev/null +++ b/tools/blender/track_lab/operators/mode_checklist.py @@ -0,0 +1,413 @@ +"""The Blender end of the Mode Checklist: reading the scene, and the Add buttons. + +What each level type needs lives in :mod:`track_lab.mode_checklist`, with no +Blender in it. This module hands it the scene - quickly, since the panel asks on +every redraw - and holds the operators the checklist's buttons run: one that +adds a per-racer set (chests, flags, Head-For Points) where the start ring +says each racer's belongs, and one that points a hub's exit at a retail level +or at another custom track. +""" + +from __future__ import annotations + +import json +import math + +import bpy +from bpy.props import EnumProperty, StringProperty +from mathutils import Vector + +from .. import (catalog as catalog_module, dkrmap, level_links, level_types, + mode_checklist, scene) +from ..gltf_io import MapObject, ObjectMap +from . import geometry as geometry_ops +from .edit import add_object + +#: The fields the checklist reads; nothing else is copied off the objects. +_FIELDS = ("racerIndex", "entranceID", "unk8", "unk9", "adjacent", "playerID", + "playerIndex", "destinationMapId", "returnSpawnIndex", "nodeID") + +#: The types whose position the checklist reads. +_PLACED = (mode_checklist.AINODE, mode_checklist.BALLOON) + + +def scene_map(context) -> ObjectMap: + """The scene's objects with just the fields the checklist reads. + + A full :func:`scene.export_object_map` reads every field and every world + matrix; this runs on each redraw of the panel, so it copies a handful of + custom properties instead. Document order follows ``dkr_order``, as the + export's does, so positions select the same objects. + """ + objects = sorted(scene.iter_dkr_objects(context), + key=lambda o: o.get("dkr_order", 1 << 30)) + found = [] + for obj in objects: + fields = {} + for name in _FIELDS: + if name in obj: + value = obj[name] + fields[name] = value.to_list() if hasattr(value, "to_list") else value + track = scene.destination_track(obj) + if track: + fields[level_links.TRACK_FIELD] = track + object_id = str(obj[scene.PROP_ID]) + # Only the weapon-node rows measure anything, between AI nodes and + # balloons; every other object keeps the origin and skips the matrix. + where = (scene.to_map(obj.matrix_world.translation) + if object_id in _PLACED else [0.0, 0.0, 0.0]) + found.append(MapObject(object_id=object_id, name=obj.name, + translation=where, fields=fields)) + return ObjectMap(objects=found) + + +def surfaces_in_use(context): + """``SurfaceType`` names the track geometry's materials carry, or ``None``. + + ``None`` when no geometry is loaded, so the egg nests read as unknown + rather than missing. Per material, as the game keeps it: the surface is a + byte on the texture entry the faces are drawn with. + """ + meshes = geometry_ops.geometry_objects(context) + if not meshes: + return None + names = set() + for obj in meshes: + for slot in obj.material_slots: + material = slot.material + if material is not None and geometry_ops.PROP_SURFACE in material: + names.add(geometry_ops.surface_name(material[geometry_ops.PROP_SURFACE])) + return names + + +def checklist(context): + """The rows for the scene's level type; empty where the checklist is silent.""" + key = level_types.current_key(context.scene.dkr) + if not mode_checklist.applies(key): + return [] + boss, surfaces = validation_facts(context) + rows = mode_checklist.rows(scene_map(context), key, boss, surfaces) + # A row names objects by document position; Select finds them by + # dkr_order, which deleting objects leaves with gaps. + orders = [int(o.get("dkr_order", -1)) for o in + sorted(scene.iter_dkr_objects(context), + key=lambda o: o.get("dkr_order", 1 << 30))] + for row in rows: + row.objects = [orders[p] for p in row.objects if 0 <= p < len(orders)] + return rows + + +def validation_facts(context): + """``(boss, surfaces)`` for :func:`validate.validate`, from the scene.""" + key = level_types.current_key(context.scene.dkr) + boss = context.scene.dkr.boss if key == level_types.BOSS else None + surfaces = surfaces_in_use(context) if key == level_types.EGGS else None + return boss, surfaces + + +# --------------------------------------------------------------------------- +# Per-racer sets +# --------------------------------------------------------------------------- + +#: kind -> (object id, the field naming the racer, what racer 0 stores, +#: fields that select which of the type's objects count, how far behind the +#: start it goes, how high above the ground, the label for a report). +SETS = { + "CHESTS": (mode_checklist.CHEST, "playerID", 1, None, 300.0, 0.0, + "treasure chest"), + "FLAGS": (mode_checklist.FLAG, "playerIndex", 0, None, 300.0, 230.0, + "racer flag"), + "HEAD_DROP": (mode_checklist.HEAD_FOR, "unk9", 0, + {"unk8": mode_checklist.HEAD_DROP}, 500.0, 0.0, + "Head-For Point"), + "HEAD_HOME": (mode_checklist.HEAD_FOR, "unk9", 0, + {"unk8": mode_checklist.HEAD_HOME}, 250.0, 0.0, + "Head-For Point"), +} + + +def racer_starts(context): + """``{racer: Blender start position object}`` at entrance 0, racers 0-3.""" + found = {} + for obj in scene.iter_dkr_objects(context): + if str(obj.get(scene.PROP_ID)) != level_types.SETUPPOINT: + continue + if int(obj.get("entranceID", 0)) != 0: + continue + racer = int(obj.get("racerIndex", -1)) + if racer in mode_checklist.RACERS: + found.setdefault(racer, obj) + return found + + +def _behind(start, distance): + """A point ``distance`` behind a start position, on its own level.""" + yaw = scene.world_yaw(start) + origin = start.matrix_world.translation + # Local +Y is forward (see the start grid), so behind is -Y turned by yaw. + return Vector((origin.x + math.sin(yaw) * distance, + origin.y - math.cos(yaw) * distance, origin.z)) + + +def _anchor(context, kind, racer, start): + """Where one racer's object of this kind goes. + + A flag stands over that racer's chest, or over its nest's Head-For Point + in Eggs, when there is one: that is what the flag is for. Everything else + goes behind the racer's start position, which is the one place the scene + already says belongs to that racer. + """ + if kind == "FLAGS": + for obj in scene.iter_dkr_objects(context): + object_id = str(obj.get(scene.PROP_ID)) + if object_id == mode_checklist.CHEST and int(obj.get("playerID", 0)) == racer + 1: + return obj.matrix_world.translation.copy() + if (object_id == mode_checklist.HEAD_FOR + and int(obj.get("unk8", -1)) == mode_checklist.HEAD_DROP + and int(obj.get("unk9", -1)) == racer): + return obj.matrix_world.translation.copy() + return _behind(start, SETS[kind][4]) + + +def add_racer_set(context, kind): + """Add the objects of one kind that racers 0-3 are missing; the new objects.""" + object_id, field, base, match, _distance, lift, _label = SETS[kind] + catalog = catalog_module.load() + object_type = catalog.get(object_id) + starts = racer_starts(context) + missing = [r for r in mode_checklist.missing_racers( + scene_map(context), object_id, field, match) if r in starts] + targets = geometry_ops.geometry_objects(context) + context.view_layer.update() + depsgraph = context.evaluated_depsgraph_get() if targets else None + created = [] + for racer in missing: + location = _anchor(context, kind, racer, starts[racer]) + if targets and not lift: + landing = geometry_ops.surface_below(location, targets, depsgraph) + if landing is not None: + location = landing + location = location + Vector((0.0, 0.0, lift)) + fields = {field: racer + base} + fields.update(match or {}) + empty = add_object(context, object_type, location, fields, + slot=level_types.tab_of(object_type)) + created.append(empty) + return created + + +class DKR_OT_add_racer_set(bpy.types.Operator): + """Add one object per racer that does not have one yet, behind each + racer's start position, with the racer already filled in""" + + bl_idname = "dkr.add_racer_set" + bl_label = "Add Per-Racer Objects" + bl_options = {"REGISTER", "UNDO"} + + kind: EnumProperty( + name="Kind", + items=[ + ("CHESTS", "Treasure Chests", + "One chest per racer, playerID = racer + 1: where that racer banks " + "bananas, from within 225 units"), + ("FLAGS", "Racer Flags", + "One Character Flag per racer, over its chest or nest, showing " + "that racer's portrait"), + ("HEAD_FOR", "Head-For Points", + "Per racer, a kind 0 point at the nest - where a bot carrying an " + "egg drops it - and a kind 2 point on the way there"), + ], + options={"SKIP_SAVE"}, + ) + + @classmethod + def description(cls, context, properties): + for item in cls.bl_rna.properties["kind"].enum_items: + if item.identifier == properties.kind: + return ("%s. Only racers without one get one, placed behind " + "their start position and dropped onto the track: " + "move them where they belong" % item.description) + return cls.__doc__ + + @classmethod + def poll(cls, context): + if len(racer_starts(context)) < len(mode_checklist.RACERS): + cls.poll_message_set( + "Build the start ring first: each racer's objects go behind " + "that racer's start position, racerIndex 0-3 at entrance 0") + return False + return True + + def execute(self, context): + kinds = ("HEAD_DROP", "HEAD_HOME") if self.kind == "HEAD_FOR" else (self.kind,) + created = [] + for kind in kinds: + created += add_racer_set(context, kind) + if not created: + self.report({"INFO"}, "every racer already has one") + return {"CANCELLED"} + for obj in context.selected_objects: + obj.select_set(False) + for obj in created: + obj.select_set(True) + context.view_layer.objects.active = created[-1] + label = SETS[kinds[0]][6] + message = "added %d %s%s" % (len(created), label, "s" if len(created) != 1 else "") + if self.kind == "HEAD_FOR": + message += ("; now give each nest floor its surface, Egg 1 to 4, " + "in Geometry > Surface") + self.report({"INFO"}, message) + return {"FINISHED"} + + +# --------------------------------------------------------------------------- +# Hub exits +# --------------------------------------------------------------------------- + +_LEVEL_ITEMS = [] + + +def retail_label(asset_id: str, tree=None) -> str: + """``ASSET_LEVEL_FOSSILCANYON`` -> ``Fossil Canyon``, from the extraction + when one is configured, which knows where the words break.""" + bare = asset_id.replace("ASSET_LEVEL_", "") + if tree is not None: + try: + for level in tree.levels(): + if level.name.split("_v")[0].upper() == bare: + return level.label + except Exception: # noqa: BLE001 - a label must not raise + pass + return bare.title() + + +def _level_items(self, context): + """Every level an exit can name, by its retail name, built once.""" + if _LEVEL_ITEMS: + return _LEVEL_ITEMS + from .. import prefs # noqa: PLC0415 + try: + object_type = catalog_module.load().get(level_links.EXIT) + field = object_type.field(level_links.DESTINATION_FIELD) + values = list(field.values) + except Exception: # noqa: BLE001 - an enum callback must not raise + values = [] + tree = prefs.resolve(context) + rows = sorted(((retail_label(v, tree), v) for v in values), key=lambda r: r[0]) + _LEVEL_ITEMS.extend((value, text, value) for text, value in rows) + return _LEVEL_ITEMS or [("NONE", "no levels in the catalogue", "")] + + +def describe_destination(obj, tree=None) -> str: + """What an exit leads to, in the words the panel shows.""" + track = scene.destination_track(obj) + if track: + return track + value = obj.get(level_links.DESTINATION_FIELD) + absent = scene._load_json(obj, scene.PROP_ABSENT, []) + if not value or level_links.DESTINATION_FIELD in absent: + return "" + return retail_label(str(value), tree) + + +class DKR_OT_set_exit_destination(bpy.types.Operator): + """Choose where this exit leads: a retail level, or another custom track + by its id""" + + bl_idname = "dkr.set_exit_destination" + bl_label = "Exit Destination" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + kind: EnumProperty( + name="Leads To", + items=[ + ("RETAIL", "Retail Level", + "A level of the original game. Its id is written into the exit"), + ("CUSTOM", "Custom Track", + "Another .dkrmap track, by the id its Package panel shows. Its " + "level id only exists once DKR-R knows which tracks are " + "installed, so the exit ships pointing at the Central Area hub " + "and DKR-R fills the real one in as the hub loads. If the track " + "is not installed, the exit leads to the Central Area hub"), + ], + default="RETAIL", + ) + level: EnumProperty(name="Level", items=_level_items, + description="The retail level this exit warps to") + track: StringProperty( + name="Track Id", + description=("The id of the custom track this exit leads to, as that " + "track's Package panel shows it: lowercase words joined " + "by hyphens"), + ) + + @classmethod + def poll(cls, context): + obj = context.active_object + if obj is None or str(obj.get(scene.PROP_ID, "")) != level_links.EXIT: + cls.poll_message_set("Select an exit first") + return False + return True + + def invoke(self, context, event): + obj = context.active_object + track = scene.destination_track(obj) + self.kind = "CUSTOM" if track else "RETAIL" + self.track = track + current = str(obj.get(level_links.DESTINATION_FIELD, "")) + if current and any(item[0] == current for item in _level_items(self, context)): + self.level = current + return context.window_manager.invoke_props_dialog(self, width=360) + + def draw(self, context): + layout = self.layout + layout.row().prop(self, "kind", expand=True) + if self.kind == "RETAIL": + layout.prop(self, "level") + else: + layout.prop(self, "track") + column = layout.column(align=True) + column.active = False + column.label(text="Filled in by DKR-R when the hub loads.") + column.label(text="Not installed: leads to the Central Area hub.") + + def execute(self, context): + obj = context.active_object + if self.kind == "CUSTOM": + track = self.track.strip() + try: + dkrmap.validate_id(track) + except dkrmap.DkrMapError: + suggestion = dkrmap.normalise_id(track) + self.report({"ERROR"}, "%r is not a track id; did you mean %r?" + % (track, suggestion)) + return {"CANCELLED"} + obj[scene.PROP_DESTINATION_TRACK] = track + value = level_links.PLACEHOLDER_LEVEL + else: + if self.level in ("", "NONE"): + self.report({"ERROR"}, "no level chosen") + return {"CANCELLED"} + if scene.PROP_DESTINATION_TRACK in obj: + del obj[scene.PROP_DESTINATION_TRACK] + value = self.level + obj[level_links.DESTINATION_FIELD] = value + # An exit imported without a destination lists the field as absent, + # and an absent field is skipped on export whatever it now holds. + absent = scene._load_json(obj, scene.PROP_ABSENT, []) + if level_links.DESTINATION_FIELD in absent: + absent = [name for name in absent if name != level_links.DESTINATION_FIELD] + if absent: + obj[scene.PROP_ABSENT] = json.dumps(sorted(absent)) + else: + del obj[scene.PROP_ABSENT] + for area in context.screen.areas if context.screen else (): + area.tag_redraw() + self.report({"INFO"}, "exit leads to %s" % describe_destination(obj)) + return {"FINISHED"} + + +CLASSES = ( + DKR_OT_add_racer_set, + DKR_OT_set_exit_destination, +) diff --git a/tools/blender/track_lab/operators/pack.py b/tools/blender/track_lab/operators/pack.py index 6bdd64ff..264d2c19 100644 --- a/tools/blender/track_lab/operators/pack.py +++ b/tools/blender/track_lab/operators/pack.py @@ -59,7 +59,10 @@ def execute(self, context): return {"CANCELLED"} if self.validate_first: - report = validate.validate(object_map, catalog, level_key=key) + from . import mode_checklist # noqa: PLC0415 + boss, surfaces = mode_checklist.validation_facts(context) + report = validate.validate(object_map, catalog, level_key=key, + boss=boss, surfaces=surfaces) from . import waterfall report = validate.Report(list(report) + waterfall.issues(context)) if report.errors: diff --git a/tools/blender/track_lab/operators/race_ai.py b/tools/blender/track_lab/operators/race_ai.py index 2d574322..c85a1e24 100644 --- a/tools/blender/track_lab/operators/race_ai.py +++ b/tools/blender/track_lab/operators/race_ai.py @@ -46,7 +46,7 @@ def checkpoint_empties(context): """The scene's checkpoints in document order, which is spawn order - the - order the game counts to its 60-gate ceiling in.""" + order the game counts to its gate ceiling in (``race_ai.MAX_CHECKPOINTS``).""" found = [obj for obj in scene.iter_dkr_objects(context) if str(obj.get(scene.PROP_ID)) == race_ai.CHECKPOINT] found.sort(key=lambda obj: (int(obj.get("dkr_order", 1 << 30)), obj.name)) diff --git a/tools/blender/track_lab/props.py b/tools/blender/track_lab/props.py index fabcc6d8..f34ea06e 100644 --- a/tools/blender/track_lab/props.py +++ b/tools/blender/track_lab/props.py @@ -543,6 +543,69 @@ class DKR_SceneSettings(bpy.types.PropertyGroup): update=_redraw_update, ) + # -- the AI node graph ------------------------------------------------- + + auto_weapon_node: BoolProperty( + name="Auto Weapon Node", + description=( + "Bots in Battle and Bananas never see balloons: they follow the AI " + "node graph and fetch weapons at nodes marked destination class 1. " + "With this on, placing a weapon balloon also places a weapon node " + "on it, parented so it moves with the balloon, and links it to the " + "nearest graph nodes. Turn off for a balloon only players take" + ), + default=True, + ) + + show_ai_graph: BoolProperty( + name="Show AI Graph", + description=( + "Draw the AI node graph in the viewport: every link, weapon nodes " + "ringed in their balloon's colour with a green or red circle for " + "whether a balloon is close enough to be taken, and bank nodes in " + "their racer's colour. Select a node to see how far everything is " + "from it and the route a bot without a weapon drives from there" + ), + default=False, + update=_redraw_update, + ) + + ai_graph_on_top: BoolProperty( + name="Draw On Top", + description=( + "Draw the graph through the track instead of behind it, so nodes " + "under a floor or behind a wall still show" + ), + default=True, + update=_redraw_update, + ) + + ai_graph_colour: EnumProperty( + name="Colour By", + description=( + "How the selected node's heat map measures how far each node is: " + "red next to it, through orange, yellow and green, to blue the " + "furthest. Grey nodes cannot be reached from it at all" + ), + items=[ + ("STEPS", "Steps", "By links: neighbours are one step, theirs two"), + ("DISTANCE", "Distance", + "By length along the links, as the bots' pathfinder measures it"), + ], + default="STEPS", + update=_redraw_update, + ) + + ai_graph_show_ids: BoolProperty( + name="Show Node IDs", + description=( + "Write each node's nodeID beside it - the numbers the links, the " + "DKR Object panel and validation use" + ), + default=False, + update=_redraw_update, + ) + # -- the minimap ------------------------------------------------------- # # How the picture looks. Where the dots go is never a setting: the numbers diff --git a/tools/blender/track_lab/race_ai.py b/tools/blender/track_lab/race_ai.py index 3c183fac..e856f18a 100644 --- a/tools/blender/track_lab/race_ai.py +++ b/tools/blender/track_lab/race_ai.py @@ -9,9 +9,9 @@ **The line.** ``checkpoint_update_all`` (``objects.c``) keeps the checkpoints whose ``vehicleType`` equals ``header.unk4F[vehicle]``, at most -:data:`MAX_CHECKPOINTS` of them, numbers an alternate-route gate ``index + 255``, -bubble-sorts on that, and pairs each alternate with the main gate of the same -``index``. ``func_80045C48`` (``racer.c``) then takes four consecutive gates, +:data:`MAX_CHECKPOINTS` of them (60 on retail, 127 on DKR-R), numbers an +alternate-route gate ``index + 255``, bubble-sorts on that, and pairs each +alternate with the main gate of the same ``index``. ``func_80045C48`` (``racer.c``) then takes four consecutive gates, moves each by the racer's lane - x += node.scale * cos(yaw) * lateral @@ -58,8 +58,18 @@ #: ``gNumberOfMainCheckpoints < MAX_CHECKPOINTS`` (``objects.c``): gates past #: this many in one set are never loaded, alternates included, and nothing says -#: so in game. -MAX_CHECKPOINTS = 60 +#: so in game. Retail's ``MAX_CHECKPOINTS`` is 60; DKR-R patches the table's +#: allocation and that bound in ``allocate_object_pools`` and +#: ``checkpoint_update_all`` up to 127, because a gate's place in the table +#: lives in signed bytes - ``Object_Racer.nextCheckpoint``, +#: ``Object_Weapon.checkpoint``, ``CheckpointNode.altRouteID`` - and 127 is the +#: most for which ``nextCheckpoint + 1`` still fits one. So this is the runtime's +#: ceiling, not the format's. +MAX_CHECKPOINTS = 127 + +#: What retail, original hardware and older DKR-R builds load per set. A set +#: past this plays on a current DKR-R and loses its last gates elsewhere. +RETAIL_MAX_CHECKPOINTS = 60 #: ``obj_init_checkpoint`` clamps a smaller scale byte up to this. MIN_SCALE = 5 @@ -136,7 +146,7 @@ class Route: """The checkpoints one vehicle's racers load, in the order they use them.""" __slots__ = ("vehicle_set", "main", "alternates", "alternate_of", - "duplicates", "unpaired", "dropped") + "duplicates", "unpaired", "dropped", "past_retail") def __init__(self, vehicle_set: int): self.vehicle_set = vehicle_set @@ -153,6 +163,9 @@ def __init__(self, vehicle_set: int): self.unpaired: List[Node] = [] #: Gates in this set past :data:`MAX_CHECKPOINTS`, never loaded. self.dropped = 0 + #: Gates loaded here past :data:`RETAIL_MAX_CHECKPOINTS`: the ones an + #: older DKR-R build or original hardware would not load. + self.past_retail = 0 def node_at(self, position: int, alternate: bool = False) -> Node: """``find_next_checkpoint_node``: the gate a racer uses at a position.""" @@ -190,6 +203,7 @@ def build_route(map_objects: Iterable, vehicle_set: int = 0) -> Route: route.dropped += 1 continue loaded.append(node_from(obj)) + route.past_retail = max(0, len(loaded) - RETAIL_MAX_CHECKPOINTS) # A bubble sort that swaps only on strictly less is stable, and so is # Python's sort: gates sharing an id keep the order they were spawned in. diff --git a/tools/blender/track_lab/scene.py b/tools/blender/track_lab/scene.py index 1f880da6..dfcf38fa 100644 --- a/tools/blender/track_lab/scene.py +++ b/tools/blender/track_lab/scene.py @@ -31,7 +31,7 @@ import bpy from mathutils import Vector -from . import catalog as catalog_module +from . import catalog as catalog_module, level_links from .gltf_io import MapObject, ObjectMap #: Custom property names the addon owns. Everything else on an object is a @@ -41,7 +41,11 @@ PROP_ABSENT = "dkr_absent" PROP_UNKNOWN = "dkr_unknown" PROP_SLOT = "dkr_slot" -RESERVED = {PROP_ID, PROP_NODE_NAME, PROP_ABSENT, PROP_UNKNOWN, PROP_SLOT} +#: On an exit: the id of the custom track it leads to, or absent for a retail +#: destination. See :mod:`.level_links` for why it is not the field itself. +PROP_DESTINATION_TRACK = "dkr_destination_track" +RESERVED = {PROP_ID, PROP_NODE_NAME, PROP_ABSENT, PROP_UNKNOWN, PROP_SLOT, + PROP_DESTINATION_TRACK} #: A start grid is an Empty that the start positions are parented to, so the #: whole grid moves and turns as one. It carries no ``dkr_id`` and so is never @@ -190,6 +194,10 @@ def create_empty(context, obj: MapObject, catalog, root, tree=None, absent.append(field.name) for name, value in obj.fields.items(): + if name == level_links.TRACK_FIELD and obj.object_id == level_links.EXIT: + if isinstance(value, str) and value.strip(): + empty[PROP_DESTINATION_TRACK] = value.strip() + continue if name not in known: unknown[name] = value @@ -397,6 +405,9 @@ def read_object(empty: bpy.types.Object, catalog) -> MapObject: fields[field.name] = field.coerce(value) fields.update(_load_json(empty, PROP_UNKNOWN, {})) + track = destination_track(empty) + if track: + fields[level_links.TRACK_FIELD] = track return MapObject( object_id=object_id, @@ -406,6 +417,14 @@ def read_object(empty: bpy.types.Object, catalog) -> MapObject: ) +def destination_track(empty) -> str: + """The custom track an exit leads to, or ``""`` when it leads to a retail level.""" + if str(empty.get(PROP_ID, "")) != level_links.EXIT: + return "" + value = empty.get(PROP_DESTINATION_TRACK, "") + return value.strip() if isinstance(value, str) else "" + + def _load_json(empty, key, fallback): raw = empty.get(key) if not raw: diff --git a/tools/blender/track_lab/ui/panels.py b/tools/blender/track_lab/ui/panels.py index 2b4da368..088513ba 100644 --- a/tools/blender/track_lab/ui/panels.py +++ b/tools/blender/track_lab/ui/panels.py @@ -22,11 +22,14 @@ import bpy -from .. import (catalog as catalog_module, level_types, prefs, race_ai, scene, - skyboxes, transparency as looks) +from .. import (ai_graph, catalog as catalog_module, level_links, level_types, + mode_checklist, prefs, race_ai, scene, skyboxes, + transparency as looks) +from ..operators import ai as ai_ops from ..operators import geometry as geometry_ops from ..operators import header as header_ops from ..operators import level_type as level_type_ops +from ..operators import mode_checklist as checklist_ops from ..operators import new_track as new_track_ops from ..operators import race_ai as race_ai_ops from ..operators import rom_assets as rom_assets_ops @@ -405,6 +408,128 @@ def _chosen(self, context): layout.prop(settings, "laps") +class DKR_PT_mode_checklist(DkrPanel, bpy.types.Panel): + """What this kind of level needs before the game can play it, each with + why, and a button that adds it where one can""" + + bl_label = "Mode Checklist" + bl_idname = "DKR_PT_mode_checklist" + bl_parent_id = "DKR_PT_level_type" + + @classmethod + def poll(cls, context): + return (not prefs.setup_pending(context) + and mode_checklist.applies(_key(context))) + + def draw_header_preset(self, context): + rows = _checklist_or_none(context) + if rows: + _dim_label(self.layout, mode_checklist.summary(rows)) + + def draw(self, context): + layout = self.layout + rows = _checklist_or_none(context) + if rows is None: + info_box(layout, context, "The checklist could not be read.", + icon="ERROR", alert=True) + return + column = layout.column() + for row in rows: + _draw_check(column, context, row) + lines(layout, context, "Validate reports the same list, and an error " + "here stops the export.", dim=True) + + +def _checklist_or_none(context): + try: + return checklist_ops.checklist(context) + except Exception: # noqa: BLE001 - the panel must still draw + return None + + +#: Row state -> icon. A missing row that would stop the export is drawn red. +_CHECK_ICONS = {mode_checklist.OK: "CHECKMARK", mode_checklist.MISSING: "ERROR", + mode_checklist.ADVISE: "ERROR", + mode_checklist.NOTE: "INFO", mode_checklist.UNKNOWN: "INFO"} + +#: The states a row has something to point at or fix in. +_ACTIONABLE = (mode_checklist.MISSING, mode_checklist.ADVISE) + + +def _draw_check(layout, context, row): + """One requirement: its state and label, then - unless it is met - why, + and the button that fixes it.""" + block = layout.column(align=True) + head = block.row(align=True) + head.alert = (row.status == mode_checklist.MISSING + and row.severity == mode_checklist.ERROR) + head.label(text=row.label, icon=_CHECK_ICONS.get(row.status, "INFO")) + if row.objects and row.status in _ACTIONABLE: + head.operator("dkr.select_issue", text="", icon="RESTRICT_SELECT_OFF", + emboss=False).objects = ",".join(str(o) for o in row.objects) + if row.ok: + return + lines(block, context, row.detail, icon="BLANK1", dim=True) + if row.status in _ACTIONABLE and row.fix: + button = block.row(align=True) + button.label(text="", icon="BLANK1") + _draw_fix(button, context, row.fix) + layout.separator(factor=0.5) + + +def _draw_fix(layout, context, fix): + """The button a checklist row offers, reusing the operator that does it.""" + key = _key(context) + if fix == mode_checklist.FIX_START: + text = ("Add Entrance Point" if key == level_types.HUB else + "Build Start Ring" if level_types.is_challenge(key) else + "Generate Start Grid") + layout.operator("dkr.generate_start_grid", text=text, icon="EMPTY_ARROWS") + elif fix == mode_checklist.FIX_AI_LINE: + layout.operator("dkr.ai_from_curve", text="AI Line From Curve", + icon="CURVE_PATH") + elif fix == mode_checklist.FIX_AI_TAG: + layout.operator("dkr.ai_tag_destinations", icon="BOOKMARKS") + elif fix == mode_checklist.FIX_AI_LINK: + layout.operator("dkr.ai_link_weapon_nodes", icon="LINKED") + elif fix in (mode_checklist.FIX_CHESTS, mode_checklist.FIX_FLAGS, + mode_checklist.FIX_HEAD_FOR): + kind, object_id, field, name = { + mode_checklist.FIX_CHESTS: ("CHESTS", mode_checklist.CHEST, + "playerID", "Treasure Chest"), + mode_checklist.FIX_FLAGS: ("FLAGS", mode_checklist.FLAG, + "playerIndex", "Racer Flag"), + mode_checklist.FIX_HEAD_FOR: ("HEAD_FOR", None, None, + "Head-For Points"), + }[fix] + if object_id: + count = len(mode_checklist.missing_racers( + checklist_ops.scene_map(context), object_id, field)) + text = "Add %d %s%s" % (count, name, "s" if count != 1 else "") + else: + text = "Add %s" % name + layout.operator("dkr.add_racer_set", text=text, icon="ADD").kind = kind + else: + placed = { + mode_checklist.FIX_EGG_CREATOR: (mode_checklist.EGG_CREATOR, "", + "Place Egg Creator"), + mode_checklist.FIX_COIN_CREATOR: (mode_checklist.COIN_CREATOR, "", + "Place Banana Creators"), + mode_checklist.FIX_BALLOON: (level_types.WEAPON_BALLOON, "RED", + "Place Weapon Balloons"), + mode_checklist.FIX_EXIT: (level_links.EXIT, "", "Place Exit"), + mode_checklist.FIX_CHECKPOINT: (level_types.CHECKPOINT, "", + "Place Checkpoints"), + }.get(fix) + if placed is None: + return + object_id, preset, text = placed + op = layout.operator("dkr.place_object", text=text, + icon="RESTRICT_SELECT_OFF") + op.object_id = object_id + op.preset = preset + + def _sub_help(key): for family in (level_types.CHALLENGE, level_types.SPECIAL): for sub, _text, help_text in level_types.SUBTYPES[family]: @@ -1024,6 +1149,8 @@ def draw(self, context): layout.prop(settings, "show_incompatible", text="Show incompatible types%s" % (" (%d hidden)" % hidden if hidden else "")) + if key in level_types.WEAPON_NODE_KEYS: + layout.prop(settings, "auto_weapon_node") placing = DKR_OT_place_object.placing() if placing is None: @@ -1304,6 +1431,11 @@ def draw(self, context): info_box(layout, context, "Part of %s. Move or rotate the root to move the whole grid." % obj.parent.name) + if object_id == mode_checklist.AINODE: + _draw_node_role(layout, context, obj) + elif (object_id == level_types.WEAPON_BALLOON + and key in level_types.WEAPON_NODE_KEYS): + _draw_balloon_node(layout, context, obj) angle_field = object_type.angle_field if angle_field is not None: @@ -1322,11 +1454,16 @@ def draw(self, context): from .. import texture_scroll is_scroll = object_id == texture_scroll.OBJECT_ID + is_exit = object_id == level_links.EXIT if is_scroll: _draw_scroll(layout, context, obj) + if is_exit: + _draw_exit_destination(layout, context, obj) column = layout.column() drawn = 3 if is_scroll else 0 for field in object_type.fields: + if is_exit and field.name == level_links.DESTINATION_FIELD: + continue if is_scroll and field.name in ("textureIndex", "unkA", "unkB") and not settings.show_raw: continue if field.unused: @@ -1356,6 +1493,70 @@ def draw(self, context): layout.label(text="Omitted on import: %s" % absent, icon="INFO") +def _node_role(obj) -> str: + """A node's destination class in plain words.""" + value = obj.get("unk8", 0) + if value == ai_graph.CLASS_WEAPON: + balloon = ai_ops.balloon_of(obj) + whose = (" of %s" % ai_ops.balloon_label(balloon)) if balloon else "" + return "Weapon node%s: bots without a weapon come here" % whose + if isinstance(value, int) and ai_graph.CLASS_BANK <= value <= ai_graph.CLASS_BANK + 3: + return ("Bank node of racer %d: that bot drives here to bank two or " + "more bananas" % (value - ai_graph.CLASS_BANK)) + if value == ai_graph.CLASS_REENTRY: + return "Re-entry node: where bots rejoin after an elevation change" + if value == ai_graph.CLASS_PLAIN: + return "Plain node: bots drive through it" + return ai_graph.describe_class(value) + + +def _draw_node_role(layout, context, obj): + """What this node is for, who it links to, and its balloon if it has one.""" + box = layout.box() + lines(box, context, _node_role(obj), icon="INFO", tight=True) + linked = ai_ops.neighbours(obj) + _dim_label(box, ("Linked to %s" % ", ".join(str(n) for n in linked)) + if linked else "Not linked to any node", icon="LINKED" + if linked else "UNLINKED") + if ai_ops.balloon_of(obj) is not None: + box.operator("dkr.ai_select_balloon", icon="RESTRICT_SELECT_OFF") + + +def _draw_balloon_node(layout, context, obj): + """Whether the arena bots will ever come for this balloon.""" + nodes = ai_ops.weapon_nodes_of(obj) + if nodes: + node = nodes[0] + linked = ai_ops.neighbours(node) + text = ("Bots fetch it at weapon node %s, linked to %s." % ( + node.get("nodeID", "?"), ", ".join(str(n) for n in linked)) + if linked else + "Its weapon node %s is not linked to the graph yet, so no bot " + "comes for it." % node.get("nodeID", "?")) + lines(layout, context, text, icon="CHECKMARK" if linked else "ERROR", + dim=bool(linked)) + else: + lines(layout, context, "No weapon node on it: bots never see balloons, " + "so only players take this one unless a destination class 1 node " + "sits on it.", icon="INFO", dim=True) + + +def _draw_exit_destination(layout, context, obj): + """Where an exit leads: a retail level, or another custom track by id.""" + box = layout.box() + shown = checklist_ops.describe_destination(obj, prefs.resolve(context)) + row = box.row(align=True) + row.alert = not shown + split = row.split(factor=0.35, align=True) + split.label(text="Leads to") + split.operator("dkr.set_exit_destination", text=shown or "Nowhere - choose", + icon="LINKED" if scene.destination_track(obj) else "FORWARD") + if scene.destination_track(obj): + lines(box, context, "A custom track: DKR-R fills in its level id when " + "the hub loads. If it is not installed, the exit leads to the " + "Central Area hub.", dim=True, tight=True) + + def is_hidden_raw(field, show_raw: bool) -> bool: """Whether a field is a byte to hide rather than something to author. @@ -1533,9 +1734,19 @@ def draw(self, context): if route.dropped: info_box(layout, context, "%d checkpoint(s) past the first %d in this set are never " - "loaded: the game stops counting there." + "loaded: DKR-R stops counting there, alternates " + "included, and Validate refuses the export." % (route.dropped, race_ai.MAX_CHECKPOINTS), icon="ERROR", alert=True) + elif route.past_retail: + info_box(layout, context, + "This set has %d checkpoints. Current DKR-R loads up to " + "%d; older DKR-R builds and original hardware load only " + "the first %d placed and drop the other %d." + % (len(route.main) + len(route.alternates), + race_ai.MAX_CHECKPOINTS, + race_ai.RETAIL_MAX_CHECKPOINTS, route.past_retail), + icon="INFO") if route.unpaired: info_box(layout, context, "%d alternate checkpoint(s) name an index no main " @@ -1646,27 +1857,154 @@ def draw(self, context): % " and ".join(stranded), icon="ERROR", alert=True) +#: Where the AI node graph is read: the arena bots, and a hub's T.T. and Taj. +_GRAPH_KEYS = (level_types.BATTLE, level_types.BANANAS, level_types.HUB) + + +def _has_loop_or_nodes(context) -> bool: + for obj in scene.iter_dkr_objects(context): + object_id = str(obj.get(scene.PROP_ID)) + if object_id == mode_checklist.AINODE: + return True + if (object_id == level_types.MODECHANGE + and obj.get("vehicleID") == "VEHICLE_LOOPDELOOP"): + return True + return False + + class DKR_PT_ai(LevelPanel, bpy.types.Panel): + """The AI node graph: what the battle and banana bots, and a hub's T.T. + and Taj, find their way along. Race and boss bots follow the checkpoints + instead, and egg bots steer straight at their targets""" + bl_label = "AI Node Graph" bl_idname = "DKR_PT_ai" bl_options = {"DEFAULT_CLOSED"} + @classmethod + def poll(cls, context): + if not super().poll(context): + return False + key = _key(context) + if key in _GRAPH_KEYS: + return True + # A race reads the graph only on a loop-de-loop; shown there once a + # loop or a node is in the scene, so a plain circuit is not offered a + # tool that does nothing for it. + return key in (level_types.RACE, level_types.BOSS, + level_types.TEST_RACE) and _has_loop_or_nodes(context) + def draw_header_preset(self, context): - self.layout.label(text="in development", icon="EXPERIMENTAL") + count = sum(1 for o in scene.iter_dkr_objects(context) + if str(o.get(scene.PROP_ID)) == mode_checklist.AINODE) + if count: + _dim_label(self.layout, "%d nodes" % count) def draw(self, context): layout = self.layout - lines(layout, context, - "Battle and banana arenas steer their bots along this graph. Race " - "bots follow the checkpoints instead - see AI Racers. The arena " - "tools stay off until the arena AI has been studied.", - icon="INFO") + key = _key(context) + found = checklist_ops.scene_map(context) + nodes = found.count_of(mode_checklist.AINODE) + + if key in (level_types.BATTLE, level_types.BANANAS): + text = ("The bots steer along this graph: from the node nearest " + "them, node to node, to a weapon balloon's node " + "(destination class 1)%s." % ( + " or to their chest's (4 + racer)" + if key == level_types.BANANAS else "")) + elif key == level_types.HUB: + text = ("T.T. and Taj walk this graph. Racers do not: a hub spawns " + "only the players.") + else: + text = ("Racers here follow the checkpoints - see AI Racers. Only a " + "loop-de-loop reads this graph: a racer crossing a Loop " + "Entry rides the nodes nearest it.") + lines(layout, context, text, icon="INFO") + _draw_graph_overlay(layout, context) + + linked = sum(1 for obj in found.objects + if obj.object_id == mode_checklist.AINODE + and any(a != 255 for a in (obj.fields.get("adjacent") or []))) + if nodes: + _dim_label(layout, "%d nodes, %d linked" % (nodes, linked), + icon="CHECKMARK" if linked >= 2 else "ERROR") + column = layout.column(align=True) - column.enabled = False column.operator("dkr.ai_from_curve", text="AI Line From Curve", icon="CURVE_PATH") column.operator("dkr.ai_add_branch", text="Add Branch", icon="CURVE_BEZCIRCLE") + active = context.active_object + if active is None or active.type != "CURVE": + lines(layout, context, "Draw a curve along the way the bots should " + "go (Add > Curve), select it, then sample it. A branch is a " + "second curve that starts and ends near the line.", dim=True) + + if nodes: + row = layout.row(align=True) + row.operator("dkr.ai_link_nodes", text="Link", icon="LINKED") + row.operator("dkr.ai_unlink_nodes", text="Unlink", icon="UNLINKED") + + if key in (level_types.BATTLE, level_types.BANANAS) and nodes: + layout.separator() + classes = {} + for obj in found.objects: + if obj.object_id == mode_checklist.AINODE: + value = obj.fields.get("unk8", 0) + classes[value] = classes.get(value, 0) + 1 + text = "Weapon nodes: %d" % classes.get(mode_checklist.CLASS_BALLOON, 0) + if key == level_types.BANANAS: + text += " · bank nodes: %d/4" % sum( + 1 for r in mode_checklist.RACERS + if classes.get(mode_checklist.CLASS_BANK + r)) + _dim_label(layout, text) + column = layout.column(align=True) + column.operator("dkr.ai_tag_destinations", icon="BOOKMARKS") + if ai_ops.loose_weapon_nodes(context): + column.operator("dkr.ai_link_weapon_nodes", icon="LINKED") + lines(layout, context, "Each node's destination class is in the DKR " + "Object panel.", dim=True) + + +def _draw_graph_overlay(layout, context): + """The overlay's switches, and what it shows about the active node.""" + settings = context.scene.dkr + box = layout.box() + row = box.row(align=True) + row.prop(settings, "show_ai_graph", toggle=True, + icon="HIDE_OFF" if settings.show_ai_graph else "HIDE_ON") + row.prop(settings, "ai_graph_on_top", text="", toggle=True, icon="XRAY") + row.prop(settings, "ai_graph_show_ids", text="", toggle=True, + icon="LINENUMBERS_ON") + if not settings.show_ai_graph: + return + _labelled(box, "Colour by").row(align=True).prop( + settings, "ai_graph_colour", expand=True) + + active = context.view_layer.objects.active if context.view_layer else None + if not ai_ops.is_node(active): + lines(box, context, "Select a node to colour the graph by how far each " + "node is from it, and to see the route a bot without a weapon " + "drives from there.", dim=True, tight=True) + return + try: + found = ai_ops.graph_geometry(context) + except Exception: # noqa: BLE001 - the panel must still draw + return + route = found["route_ids"] + start = ai_ops.node_id(active) + if route: + steps = " > ".join(str(n) for n in route) + if len(route) > 7: + steps = " > ".join(str(n) for n in route[:3]) + " > ... > " + \ + " > ".join(str(n) for n in route[-2:]) + lines(box, context, "Bot route from %s to the nearest weapon node: %s " + "(dashed)" % (start, steps), icon="TRACKING", tight=True) + elif any(int(o.get("unk8", 0)) == ai_graph.CLASS_WEAPON + for o in ai_ops.node_empties(context)): + info_box(box, context, "No weapon node can be reached from node %s: a " + "bot here never finds a weapon. Link this part of the graph " + "to the rest." % start, icon="ERROR", alert=True) class DKR_PT_validate(LevelPanel, bpy.types.Panel): @@ -2049,6 +2387,7 @@ def _wrap(text, width): DKR_MT_default_vehicle, DKR_PT_assets, DKR_PT_level_type, + DKR_PT_mode_checklist, DKR_PT_track, DKR_PT_geometry, DKR_PT_textures, diff --git a/tools/blender/track_lab/validate.py b/tools/blender/track_lab/validate.py index 6fadf9dd..1fc87cfc 100644 --- a/tools/blender/track_lab/validate.py +++ b/tools/blender/track_lab/validate.py @@ -19,7 +19,8 @@ import collections from typing import List, Optional -from . import ai_graph, catalog as catalog_module, level_types +from . import (ai_graph, catalog as catalog_module, level_links, level_types, + mode_checklist, race_ai) from .gltf_io import ObjectMap AINODE = "ASSET_OBJECT_AINODE" @@ -82,7 +83,8 @@ def __iter__(self): def validate(object_map: ObjectMap, catalog=None, require_racing_track=True, - level_key: Optional[str] = None) -> Report: + level_key: Optional[str] = None, boss: Optional[str] = None, + surfaces=None) -> Report: """Run every check over an object map. ``level_key`` is the track's :mod:`level_types` key, and when given it @@ -93,6 +95,11 @@ def validate(object_map: ObjectMap, catalog=None, require_racing_track=True, Without one, ``require_racing_track`` is the older and blunter switch, which the retail sanity check still uses: it turns off the checks that only make sense for a track people race on. + + A challenge, a hub or a boss race is also held to its Mode Checklist + (:mod:`mode_checklist`), with ``boss`` the scene's boss and ``surfaces`` + the ``SurfaceType`` names its geometry uses - both ``None`` when unknown, + which skips the rules that need them. """ if catalog is None: catalog = catalog_module.load() @@ -110,11 +117,17 @@ def validate(object_map: ObjectMap, catalog=None, require_racing_track=True, issues += _check_checkpoints(object_map, require_racing_track) issues += _check_setup_points(object_map, require_racing_track) else: + # A boss race's missing checkpoints are its checklist's error, not + # this warning as well. issues += _check_checkpoints(object_map, - level_types.needs_checkpoints(level_key)) + level_types.needs_checkpoints(level_key) + and level_key != level_types.BOSS) issues += _check_start_grid(object_map, level_key) issues += _check_level_type(object_map, catalog, level_key) - issues += _check_exits(object_map, catalog) + issues += [Issue(severity, message, object_id, objects=where) + for severity, message, object_id, where in + mode_checklist.issues(object_map, level_key, boss, surfaces)] + issues += _check_exits(object_map, catalog, level_key) issues += _check_budget(object_map, catalog) return Report(issues) @@ -443,6 +456,56 @@ def _check_checkpoints(object_map: ObjectMap, require_racing_track: bool) -> Lis sum(len(w) for w in indices.values()), min(indices), max(indices)), CHECKPOINT, )) + issues += _check_checkpoint_counts(checkpoints, positions) + return issues + + +def _check_checkpoint_counts(checkpoints, positions) -> List[Issue]: + """A vehicle set holds at most ``race_ai.MAX_CHECKPOINTS`` gates. + + ``checkpoint_update_all`` loads one set's gates, main and alternate route + together, into a fixed table in spawn order and silently skips the rest. + Retail's table holds 60. DKR-R's holds 127: a gate's place in it is kept in + signed bytes (``Object_Racer.nextCheckpoint``, ``CheckpointNode.altRouteID``), + so 127 is the runtime's real ceiling, and past it a set is an error. Past 60 + it still plays on DKR-R, but older builds and original hardware drop gates, + which is worth a warning. + """ + sets = collections.defaultdict(list) + for checkpoint in checkpoints: + sets[checkpoint.fields.get("vehicleType", 0)].append( + positions.get(id(checkpoint))) + issues = [] + for vehicle_type, where in sorted(sets.items()): + count = len(where) + if count > race_ai.MAX_CHECKPOINTS: + issues.append(Issue( + ERROR, + "vehicle type %s has %d checkpoints, main and alternate route " + "together; DKR-R loads at most %d per vehicle type (the " + "racer's next-checkpoint counter is a signed byte) and " + "silently skips the last %d placed, so their laps and AI line " + "break. Older DKR-R builds and original hardware load only %d" + % (vehicle_type, count, race_ai.MAX_CHECKPOINTS, + count - race_ai.MAX_CHECKPOINTS, + race_ai.RETAIL_MAX_CHECKPOINTS), + CHECKPOINT, + objects=[p for p in where[race_ai.MAX_CHECKPOINTS:] if p is not None], + )) + elif count > race_ai.RETAIL_MAX_CHECKPOINTS: + issues.append(Issue( + WARNING, + "vehicle type %s has %d checkpoints, main and alternate route " + "together; DKR-R loads up to %d, but older DKR-R builds and " + "original hardware load only the first %d placed and skip the " + "other %d" + % (vehicle_type, count, race_ai.MAX_CHECKPOINTS, + race_ai.RETAIL_MAX_CHECKPOINTS, + count - race_ai.RETAIL_MAX_CHECKPOINTS), + CHECKPOINT, + objects=[p for p in where[race_ai.RETAIL_MAX_CHECKPOINTS:] + if p is not None], + )) return issues @@ -491,14 +554,30 @@ def _check_setup_points(object_map: ObjectMap, require_racing_track: bool) -> Li return issues -def _check_exits(object_map: ObjectMap, catalog) -> List[Issue]: +def _check_exits(object_map: ObjectMap, catalog, + level_key: Optional[str] = None) -> List[Issue]: known = set(catalog.levels) if not known: return [] issues = [] for exit_object in object_map.by_id(EXIT): destination = exit_object.fields.get("destinationMapId") + track = level_links.linked_track(exit_object) + if track: + # Leads to another custom track, whose level id the runtime + # assigns; the byte it ships is a placeholder by design. + if not _is_track_id(track): + issues.append(Issue( + ERROR, + "an exit leads to custom track %r, which is not a track id: " + "lowercase words joined by single hyphens, as that track's " + "Package panel shows it" % track, + EXIT, + )) + continue if destination is None: + if level_key == level_types.HUB: + continue # The hub's checklist reports it, as an error. issues.append(Issue( WARNING, "an exit has no destinationMapId and will not lead anywhere", @@ -516,6 +595,15 @@ def _check_exits(object_map: ObjectMap, catalog) -> List[Issue]: return issues +def _is_track_id(text: str) -> bool: + from . import dkrmap # noqa: PLC0415 - dkrmap is heavier than validation needs + try: + dkrmap.validate_id(text) + except dkrmap.DkrMapError: + return False + return True + + def _check_budget(object_map: ObjectMap, catalog) -> List[Issue]: """Report the encoded size, so an author sees a map growing before it breaks.""" return [Issue( From c7b29e051eb6f68b5e4c391a116d33616381fb81 Mon Sep 17 00:00:00 2001 From: leite-matheus <78598648+leite-matheus@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:38:35 -0300 Subject: [PATCH 09/11] Link the crash report library into a plain runtime build game_main.cpp and rt64_renderer.cpp call the native crash context directly since v1.0.15, but only the release overlay linked DKRCrashReports into DKRPortGame, so a configure without the overlay failed with two unresolved externals. --- runtime-recomp/CMakeLists.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/runtime-recomp/CMakeLists.txt b/runtime-recomp/CMakeLists.txt index ef505128..79b40230 100644 --- a/runtime-recomp/CMakeLists.txt +++ b/runtime-recomp/CMakeLists.txt @@ -927,6 +927,12 @@ if(DKR_RUNTIME_BUILD_GENERATED) NOMINMAX ) target_link_libraries(DKRPortGame PRIVATE DKRUpdateClient) + # game_main.cpp and rt64_renderer.cpp call the native crash context + # directly, so a plain configure (without the release overlay) must link + # it too. dkr_add_crash_reports() defines the target only once. + include(cmake/CrashReports.cmake) + dkr_add_crash_reports() + target_link_libraries(DKRPortGame PRIVATE DKRCrashReports) include(cmake/FilePicker.cmake) dkr_add_file_picker() if(DKR_EXPERIMENTAL_RACE_TEST) From cb6f6567fdcf43927285718d76bc140dd9cc15c9 Mon Sep 17 00:00:00 2001 From: leite-matheus <78598648+leite-matheus@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:58:08 -0300 Subject: [PATCH 10/11] Fold the low-pass spray layer into the lava-glow water patch main's pattern guard refuses new first-party patch layers and string-replace pipelines, so the low-pass changes now ride on the baselined dkr-lava-glow.patch (as the caustics did in 80e4611) and dkr-lowpass.patch and compose-water-lowpass.py are removed. The folded patch applies with the same CMake flags and composes an RT64 tree byte-identical to the previous lava-glow + low-pass chain. --- patches/enhanced-water/dkr-lava-glow.patch | 904 +++++++++++++++------ patches/enhanced-water/dkr-lowpass.patch | 382 --------- runtime-recomp/cmake/EnhancedWater.cmake | 4 +- scripts/compose-water-lowpass.py | 323 -------- 4 files changed, 673 insertions(+), 940 deletions(-) delete mode 100644 patches/enhanced-water/dkr-lowpass.patch delete mode 100644 scripts/compose-water-lowpass.py diff --git a/patches/enhanced-water/dkr-lava-glow.patch b/patches/enhanced-water/dkr-lava-glow.patch index b03600f0..de62e359 100644 --- a/patches/enhanced-water/dkr-lava-glow.patch +++ b/patches/enhanced-water/dkr-lava-glow.patch @@ -1,25 +1,23 @@ -diff --git a/src/hle/rt64_workload.h b/src/hle/rt64_workload.h ---- a/src/hle/rt64_workload.h -+++ b/src/hle/rt64_workload.h -@@ -27,4 +27,8 @@ - namespace RT64 { - struct DrawData { -+ // Immutable local irradiance; the existing RSP pass interpolates its pulse. -+ std::vector lavaGlow; -+ bool lavaGlowActive=false; -+ float lavaGlowTime=0,lavaGlowStep=0,lavaGlowStrength=0,lavaGlowSpeed=1,lavaGlowIntensity=0; - std::vector posFloats; - std::vector velFloats; -@@ -146,4 +150,5 @@ - BufferPair texcoordVelocityBuffer; - BufferPair normalColorBuffer; -+ BufferPair lavaGlowBuffer; - BufferPair viewProjIndicesBuffer; - BufferPair worldIndicesBuffer; +diff --git a/src/hle/rt64_rsp.cpp b/src/hle/rt64_rsp.cpp +--- a/src/hle/rt64_rsp.cpp ++++ b/src/hle/rt64_rsp.cpp +@@ -760,6 +760,11 @@ namespace RT64 { + auto &worldIndices = workload.drawData.worldIndices; + auto &posTransformed = workload.drawData.posTransformed; + const uint32_t newIndex = workload.drawData.vertexCount(); ++ if(workload.drawData.lavaGlowActive) { ++ auto& glow=workload.drawData.lavaGlow; ++ const float value=globalIndex lavaGlow; ++ bool lavaGlowActive=false; ++ float lavaGlowTime=0,lavaGlowStep=0,lavaGlowStrength=0,lavaGlowSpeed=1,lavaGlowIntensity=0; + std::vector posFloats; + std::vector velFloats; + std::vector tcFloats; +@@ -145,6 +149,7 @@ namespace RT64 { + BufferPair texcoordBuffer; + BufferPair texcoordVelocityBuffer; + BufferPair normalColorBuffer; ++ BufferPair lavaGlowBuffer; + BufferPair viewProjIndicesBuffer; + BufferPair worldIndicesBuffer; + BufferPair fogIndicesBuffer; diff --git a/src/render/rt64_descriptor_sets.h b/src/render/rt64_descriptor_sets.h --- a/src/render/rt64_descriptor_sets.h +++ b/src/render/rt64_descriptor_sets.h -@@ -529,4 +529,5 @@ +@@ -528,6 +528,7 @@ namespace RT64 { + uint32_t dstTc; uint32_t dstCol; uint32_t dstWorld; + uint32_t srcLavaGlow; RSPProcessDescriptorSet(RenderDevice *device = nullptr) { -@@ -554,4 +555,5 @@ + builder.begin(); +@@ -553,6 +554,7 @@ namespace RT64 { + dstTc = builder.addReadWriteStructuredBuffer(20); dstCol = builder.addReadWriteStructuredBuffer(21); dstWorld = builder.addReadWriteStructuredBuffer(22); + srcLavaGlow = builder.addFormattedBuffer(23); builder.end(); -diff --git a/src/render/rt64_rsp_processor.h b/src/render/rt64_rsp_processor.h ---- a/src/render/rt64_rsp_processor.h -+++ b/src/render/rt64_rsp_processor.h -@@ -17,5 +17,8 @@ - float curFrameWeight; - uint32_t waterWorldActive; -+ float lavaGlowScale,lavaGlowCooling; -+ uint32_t padding{}; + if (device != nullptr) { +diff --git a/src/render/rt64_framebuffer_renderer.cpp b/src/render/rt64_framebuffer_renderer.cpp +--- a/src/render/rt64_framebuffer_renderer.cpp ++++ b/src/render/rt64_framebuffer_renderer.cpp +@@ -549,10 +549,12 @@ namespace RT64 { + auto finishLava = [&]() { + if(pendingLava==UINT32_MAX)return; + finishWater(); ++ const auto lavaProfileToken=waterRenderer->beginProfile(worker,4); + if(waterRenderer->drawLavaLighting(worker,pendingLava,depthState,framebuffer.viewport)) { + worker->commandList->setFramebuffer(depthState ? fbStorage->colorWriteDepthRead.get() : fbStorage->colorDepthWrite.get()); + worker->commandList->drawInstanced(3,1,0,0); + } ++ waterRenderer->endProfile(worker,lavaProfileToken); + pendingLava=UINT32_MAX;pendingLavaProjection=UINT32_MAX; + switchToGraphicsPipeline(); }; -+ static_assert(sizeof(ProcessCB)==32,"RSP HLSL constant rows"); +@@ -1359,6 +1361,7 @@ namespace RT64 { + if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->rays.record(worker); + if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordNormals(worker, *outputBuffers); + if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordField(worker); ++ if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordCaustics(worker); - struct ModifyCB { -diff --git a/src/render/rt64_shader_library.cpp b/src/render/rt64_shader_library.cpp ---- a/src/render/rt64_shader_library.cpp -+++ b/src/render/rt64_shader_library.cpp -@@ -543,5 +543,6 @@ - RSPProcessDescriptorSet descriptorSet; - layoutBuilder.begin(); -- layoutBuilder.addPushConstant(0, 0, sizeof(uint32_t) * 4, RenderShaderStageFlag::COMPUTE); -+ // Match both 16-byte HLSL constant rows, including padding. -+ layoutBuilder.addPushConstant(0, 0, sizeof(uint32_t) * 8, RenderShaderStageFlag::COMPUTE); - layoutBuilder.addDescriptorSet(descriptorSet); - layoutBuilder.end(); + if (vertexProcessor != nullptr) { + vertexProcessor->recordCommandList(worker, shaderLibrary, outputBuffers); diff --git a/src/render/rt64_rsp_processor.cpp b/src/render/rt64_rsp_processor.cpp --- a/src/render/rt64_rsp_processor.cpp +++ b/src/render/rt64_rsp_processor.cpp -@@ -6,4 +6,5 @@ +@@ -5,6 +5,7 @@ + #include "rt64_rsp_processor.h" #include "rt64_buffer_uploader.h" +#include "lava_lighting_policy.hpp" namespace RT64 { -@@ -25,4 +26,8 @@ + // RSPProcessor +@@ -24,6 +25,10 @@ namespace RT64 { + processCB.prevFrameWeight = p.prevFrameWeight; processCB.curFrameWeight = p.curFrameWeight; processCB.waterWorldActive = p.outputBuffers->waterWorldActive ? 1U : 0U; + const float glowTime=drawData.lavaGlowTime-drawData.lavaGlowStep*(1-std::clamp(p.curFrameWeight,0.0f,1.0f)); @@ -107,170 +125,56 @@ diff --git a/src/render/rt64_rsp_processor.cpp b/src/render/rt64_rsp_processor.c + dkr::runtime::water::enhanced::lava_pulse(glowTime,drawData.lavaGlowStrength,drawData.lavaGlowSpeed) : 0; p.outputBuffers->screenPosBuffer.computedSize += processCB.vertexCount * sizeof(float) * 4; p.outputBuffers->genTexCoordBuffer.computedSize += processCB.vertexCount * sizeof(float) * 2; -@@ -51,4 +56,5 @@ + p.outputBuffers->shadedColBuffer.computedSize += processCB.vertexCount * sizeof(float) * 4; +@@ -50,6 +55,7 @@ namespace RT64 { + processSet->setBuffer(processSet->dstCol, p.outputBuffers->shadedColBuffer.buffer.get(), RenderBufferStructuredView(sizeof(float) * 4)); processSet->setBuffer(processSet->dstWorld, p.outputBuffers->rasterWorldPosBuffer.buffer.get(), RenderBufferStructuredView(sizeof(float) * 4)); + processSet->setBuffer(processSet->srcLavaGlow,p.drawBuffers->lavaGlowBuffer.get(),p.drawBuffers->lavaGlowBuffer.getView(0)); const uint32_t modifyCount = drawData.modifyCount(); modifyCB.modifyCount = modifyCount; -diff --git a/src/shaders/RSPProcessCS.hlsl b/src/shaders/RSPProcessCS.hlsl ---- a/src/shaders/RSPProcessCS.hlsl -+++ b/src/shaders/RSPProcessCS.hlsl -@@ -18,4 +18,7 @@ - float curFrameWeight; - uint waterWorldActive; -+ float lavaGlowScale; -+ float lavaGlowCooling; -+ uint reserved; - }; - -@@ -43,4 +46,5 @@ - RWStructuredBuffer dstCol : register(u21); - RWStructuredBuffer dstWorld : register(u22); -+Buffer srcLavaGlow : register(t23); - - [numthreads(GROUP_SIZE, 1, 1)] -@@ -133,4 +137,9 @@ - dstPos[vertexOffsetIndex] = screenPos; - dstTc[vertexOffsetIndex] = tc; -+ if(gConstants.lavaGlowScale>0) { -+ const float energy=min(0.18,min(0.14,max(0,srcLavaGlow[vertexOffsetIndex]))*gConstants.lavaGlowScale); -+ const float3 warmth=lerp(float3(1,0.19,0.012),float3(1,0.07,0.006),saturate(gConstants.lavaGlowCooling)); -+ vertexColor.rgb=min(vertexColor.rgb+max(vertexColor.rgb,0.12)*warmth*energy,1); -+ } - dstCol[vertexOffsetIndex] = vertexColor; - if (gConstants.waterWorldActive) dstWorld[vertexOffsetIndex] = mul(worldMat, float4(pos - vel * (1.0f - gConstants.curFrameWeight), 1.0f)); -diff --git a/src/shaders/WaterPS.hlsl b/src/shaders/WaterPS.hlsl ---- a/src/shaders/WaterPS.hlsl -+++ b/src/shaders/WaterPS.hlsl -@@ -8,4 +8,6 @@ - Texture2D previousField : register(t5); - Texture2D originalColor : register(t6); -+Texture2D causticTexture : register(t7); -+SamplerState causticSampler : register(s8); - - static const float PI = 3.14159265359; -@@ -250,4 +252,8 @@ - float footprint=max(length(ddx(worldPosition.xz)),length(ddy(worldPosition.xz)))*LAVA_WORLD_SCALE; - float4 molten=lavaSurface(worldPosition.xz,p.camera.w,footprint); -+ // Surface heat and nearby glow share the interpolated clock. Pulse -+ // between hot and cooling palettes, never between lit and black. -+ const float cooling=saturate(m.sunColor.w)*0.5*(1-sin(p.camera.w*m.optics.y*1.7)); -+ molten.rgb=lavaHeatPalette(molten.rgb,cooling)*m.shoreCounts[0].z; - float roughness=clamp(m.shallowColor.a+molten.a*0.18,0.065,0.65); - float4 reflection=0; -@@ -255,5 +261,5 @@ - #ifdef HARDWARE_WATER - bool fallingHit=false,capturedHit=false; -- float4 traced=hardwareWaterReflection(worldPosition,N,V,p,fallingHit,capturedHit); -+ float4 traced=m.shoreCounts[0].y>0.5 ? hardwareWaterReflection(worldPosition,N,V,p,fallingHit,capturedHit) : float4(0,0,0,0); - if(fallingHit || capturedHit)reflection=traced; - else { -@@ -319,14 +325,16 @@ - // Reject foreground silhouettes at the proposed sample before distorting. - if (refractedThickness < 0) { refractedPixel = position.xy; refractedThickness = thickness; } -- // Derivatives must be evaluated for the whole pixel quad, before the -- // depth-validity branch. A branch crossing a shoreline otherwise leaves -- // caustic receiver normals/filter widths undefined on some backends. -- float bottomDepth=sceneDepth.Load(int3(int2(boundPixel(refractedPixel,p)),0)); -- float3 bottom=waterWorldFromPixel(refractedPixel,bottomDepth,p); -- float3 bottomNormal=cross(ddx(bottom),ddy(bottom)); -- bottomNormal*=rsqrt(max(dot(bottomNormal,bottomNormal),0.000001)); -- if (bottomNormal.y<0) bottomNormal=-bottomNormal; -- float2 causticUV=bottom.xz*0.035; -- float causticFilter=1-smoothstep(0.25,1.5,length(fwidth(causticUV))); -+ // Caustics follow the reference water workflow: one authored pattern read -+ // twice on the water plane, panned in opposite directions, the second -+ // layer rescaled so the two never align. Mipmaps keep distant cells from -+ // breaking into stair-stepped lines. Sample before the depth-validity -+ // branch: a branch crossing a shoreline leaves derivatives undefined. -+ float2 causticUV=worldPosition.xz/130+ripples*0.6; -+ float caustic=causticTexture.Sample(causticSampler,causticUV+float2(-0.05,0.05)*p.camera.w) -+ +causticTexture.Sample(causticSampler,(causticUV+float2(0.05,-0.05)*p.camera.w)*0.66); -+ // Vertical water column under this pixel. Unlike the view-ray thickness it -+ // does not grow at grazing angles. Negative without a submerged surface. -+ float waterDepth=thickness>=0 ? max(worldPosition.y-waterWorldFromPixel(position.xy, -+ sceneDepth.Load(int3(int2(position.xy),0)),p).y,0) : -1; - if (thickness >= 0 && refractedThickness >= 0) { - float3 transmission = exp(-m.deepColor.a * float3(1.8,0.55,0.30) * refractedThickness); -@@ -342,13 +350,10 @@ - // background color. Keep fractional coverage for a soft boundary. - transmission*=receiverColor.a; -- // Stable world-space light focusing, confined to an actual shallow -- // submerged receiver. Dark source lighting and steep walls reject it. -- float phaseA=causticUV.x+sin(causticUV.y*1.3+p.camera.w*0.65); -- float phaseB=causticUV.y-sin(causticUV.x*1.1-p.camera.w*0.5); -- float focusing=pow(saturate(1-abs(sin(phaseA)+sin(phaseB)*0.55)),12); -- float receiver=smoothstep(0.2,0.65,bottomNormal.y)*saturate(dot(bottomNormal,L)); -+ // Focused light only in shallow water and near the camera, and never -+ // on receivers whose source lighting is dark. -+ float receiver=smoothstep(0,2,waterDepth)*(1-smoothstep(0,60,waterDepth)); -+ receiver*=1-smoothstep(900,2200,length(p.camera.xyz-worldPosition)); - receiver*=smoothstep(0.015,0.08,dot(bottomColor,float3(0.2126,0.7152,0.0722))); -- receiver*=bottom.y draws; - BufferPair drawBuffer; -+ // Declared before every descriptor set: the immutable sampler and the -+ // texture must outlive the sets that sample them. -+ std::unique_ptr causticSampler; -+ std::unique_ptr causticTexture; -+ std::unique_ptr causticView; -+ std::unique_ptr causticUpload; -+ std::vector causticOffsets; -+ uint32_t causticSize=0; -+ bool causticPending=false; - std::unique_ptr descriptorSet; - std::unique_ptr layout; -@@ -239,5 +252,5 @@ - std::vector profileKinds; - uint32_t profileFrames=0; -- std::array profileTotals{}; -+ std::array profileTotals{}; - bool usesHDR; + modifySet->setBuffer(modifySet->srcModifyPos, p.drawBuffers->modifyPosUintsBuffer.get(), p.drawBuffers->modifyPosUintsBuffer.getView(0)); +diff --git a/src/render/rt64_rsp_processor.h b/src/render/rt64_rsp_processor.h +--- a/src/render/rt64_rsp_processor.h ++++ b/src/render/rt64_rsp_processor.h +@@ -16,7 +16,10 @@ namespace RT64 { + float prevFrameWeight; + float curFrameWeight; + uint32_t waterWorldActive; ++ float lavaGlowScale,lavaGlowCooling; ++ uint32_t padding{}; + }; ++ static_assert(sizeof(ProcessCB)==32,"RSP HLSL constant rows"); -@@ -259,4 +272,5 @@ - void prepareField(RenderWorker *worker); - void recordField(RenderWorker *worker); -+ void recordCaustics(RenderWorker *worker); - void stepSpray(const interop::WaterMaterial &material, float previousTime, float nextTime, bool reset); - void beginSurface(RenderWorker *worker,uint32_t drawIndex); + struct ModifyCB { + uint32_t modifyCount; +diff --git a/src/render/rt64_shader_library.cpp b/src/render/rt64_shader_library.cpp +--- a/src/render/rt64_shader_library.cpp ++++ b/src/render/rt64_shader_library.cpp +@@ -542,7 +542,8 @@ namespace RT64 { + { + RSPProcessDescriptorSet descriptorSet; + layoutBuilder.begin(); +- layoutBuilder.addPushConstant(0, 0, sizeof(uint32_t) * 4, RenderShaderStageFlag::COMPUTE); ++ // Match both 16-byte HLSL constant rows, including padding. ++ layoutBuilder.addPushConstant(0, 0, sizeof(uint32_t) * 8, RenderShaderStageFlag::COMPUTE); + layoutBuilder.addDescriptorSet(descriptorSet); + layoutBuilder.end(); + rspProcess.pipelineLayout = layoutBuilder.create(device); diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer.cpp --- a/src/render/rt64_water_renderer.cpp +++ b/src/render/rt64_water_renderer.cpp -@@ -12,4 +12,6 @@ +@@ -11,6 +11,8 @@ + #include #include #include +#include "stb/stb_image.h" +#include "textures/WaterCaustics.png.h" #include "shaders/WaterVS.hlsl.spirv.h" #include "shaders/WaterfallVS.hlsl.spirv.h" -@@ -125,5 +127,51 @@ + #include "shaders/WaterfallPS.hlsl.spirv.h" +@@ -124,7 +126,53 @@ WaterRenderer::WaterRenderer(RenderDevice *device, RenderShaderFormat format, bo + waterfallVS=checkedWaterResource(device->createShader(CREATE_SHADER_INPUTS(WaterfallVSBlobDXIL,WaterfallVSBlobSPIRV,WaterfallVSBlobMSL,"VSMain",format))); waterfallPS=checkedWaterResource(device->createShader(CREATE_SHADER_INPUTS(WaterfallPSBlobDXIL,WaterfallPSBlobSPIRV,WaterfallPSBlobMSL,"PSMain",format))); waterfallMSPS=checkedWaterResource(device->createShader(CREATE_SHADER_INPUTS(WaterfallMSPSBlobDXIL,WaterfallMSPSBlobSPIRV,WaterfallMSPSBlobMSL,"PSMain",format))); - descriptorSet = std::make_unique(device); @@ -323,27 +227,116 @@ diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer + descriptorSet = std::make_unique(device,causticSampler.get()); RenderPipelineLayoutBuilder builder; builder.begin(false, true); // Water vertices use the input assembler on D3D12. -@@ -234,4 +282,6 @@ + builder.addPushConstant(0, 0, sizeof(interop::WaterPushConstants), RenderShaderStageFlag::VERTEX | RenderShaderStageFlag::PIXEL); +@@ -233,6 +281,8 @@ void WaterRenderer::reset() { + wireIndices.clear(); wireStarts.clear(); profileKinds.clear(); for (auto &scene : scenes) { scene.captured=false; scene.opticalCaptured=false; scene.surfaceCleared=false; scene.lavaPrepared=false; } + // The workload that recorded the caustic upload has completed. + if (!causticPending) causticUpload.reset(); } -@@ -314,5 +364,5 @@ + uint32_t WaterRenderer::prepareScene(RenderWorker *worker, RenderTarget *color, RenderTarget *depth, uint64_t key, bool classic, bool selfDepth,water_work::Region originalRegion) { +@@ -313,7 +363,7 @@ uint32_t WaterRenderer::prepareScene(RenderWorker *worker, RenderTarget *color, + } } scene.captureSet=std::make_unique(worker->device); - scene.materialSet=std::make_unique(worker->device); + scene.materialSet=std::make_unique(worker->device,causticSampler.get()); if(!classic) { scene.surfaceView=checkedWaterResource(scene.surface->createTextureView(RenderTextureViewDesc::Texture2D(RenderFormat::R32_FLOAT))); -@@ -349,4 +399,5 @@ + scene.surfaceWidth=width;scene.surfaceHeight=height;scene.spraySet=std::make_unique(worker->device); +@@ -348,6 +398,7 @@ uint32_t WaterRenderer::prepareScene(RenderWorker *worker, RenderTarget *color, + ms->setTexture(ms->originalColor,scene.color.get(),RenderTextureLayout::SHADER_READ,scene.colorView.get()); ms->setTexture(ms->sceneColor,scene.color.get(),RenderTextureLayout::SHADER_READ,scene.colorView.get()); ms->setTexture(ms->sceneDepth,scene.depth.get(),RenderTextureLayout::SHADER_READ,scene.depthView.get()); + ms->setTexture(ms->caustics,causticTexture.get(),RenderTextureLayout::SHADER_READ,causticView.get()); if(!existing)sceneKeys.emplace(sceneKey,index); return index; -@@ -705,4 +756,16 @@ + } catch(...) { +@@ -498,9 +549,20 @@ void WaterRenderer::beginColor(RenderWorker *worker,uint32_t drawIndex) { + worker->commandList->setFramebuffer(scene.waterColorFramebuffer.get()); + } + ++// Matches waterLowPassStrength in rt64_water_params.h. ++static float lowPassStrength(float air) { ++ const auto fade=[](float full,float ceiling,float clearance) { ++ const float t=std::clamp((clearance-full)/(ceiling-full),0.0f,1.0f); ++ return 1-t*t*(3-2*t); ++ }; ++ if (air>0) return fade(WATER_LOWPASS_PLANE_FULL,WATER_LOWPASS_PLANE_CEILING,air); ++ if (air<0) return WATER_LOWPASS_HOVER_SCALE*fade(WATER_LOWPASS_HOVER_FULL,WATER_LOWPASS_HOVER_CEILING,-air); ++ return 0; ++} ++ + void WaterRenderer::stepSpray(const interop::WaterMaterial &m,float fromTime,float toTime,bool reset) { + if (reset || m.identity.x<2 || m.effects.x<0.5f) { +- particles.clear(); sprayDistance.fill(0); sprayHeading.fill({0,0,0,0}); ++ particles.clear(); sprayDistance.fill(0); sprayHeading.fill({0,0,0,0}); lowPassDistance.fill(0); + spraySeed=0x57A064u^uint32_t(m.identity.w); + return; + } +@@ -536,6 +598,45 @@ void WaterRenderer::stepSpray(const interop::WaterMaterial &m,float fromTime,flo + particles.push_back(p); + } + } ++ // Low passes lift spray from the liquid beneath the craft, not from its ++ // hull: outward curtains (kind 2) and slowly settling mist (kind 1). ++ for (uint32_t i=0;i0;}); ++ // A low pass over the liquid keeps the field alive without hull contact. ++ bool lowPass=false; ++ for (uint32_t i=0;i=0 && WATER_CRAFT_AIR(m,i)!=0; ++ const bool active=horizontal!=draws.end() && (lowPass || ++ std::any_of(std::begin(m.craftCurrent),std::end(m.craftCurrent),[](const auto& c){return c.w>0;})); + const uint32_t resolution=active && m.performance.y>=128 ? uint32_t(m.performance.y) : water_work::fieldResolution(active,bool(fieldTextures[0]),fieldResolution,uint32_t(m.identity.x)); + constexpr float extent=4096, dt=1.0f/60; + if (fieldResolution!=resolution) { +@@ -704,6 +809,18 @@ void WaterRenderer::recordField(RenderWorker *worker) { + } +void WaterRenderer::recordCaustics(RenderWorker *worker) { @@ -360,7 +353,9 @@ diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer + uint32_t WaterRenderer::beginProfile(RenderWorker *worker, uint32_t kind) { if (!profilePool || profileKinds.size() >= 64) return UINT32_MAX; -@@ -744,14 +807,14 @@ + const uint32_t token = uint32_t(profileKinds.size())*2; +@@ -743,28 +860,28 @@ void WaterRenderer::finishProfile(double frameMilliseconds) { + if (!profilePool || profileKinds.empty() || draws.empty()) return; profilePool->queryResults(); const auto *times = profilePool->getResults(); - double milliseconds[4] = {}; @@ -380,7 +375,7 @@ diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer + profileTotals[3]/profileFrames,profileTotals[4]/profileFrames,profileTotals[5]/profileFrames,static_cast(allocationBytes())); profileFrames=0;profileTotals.fill(0); } -@@ -759,11 +822,11 @@ + static FILE *csv = []() { const char *name = std::getenv("DKRR_WATER_PROFILE"); FILE *file = name ? std::fopen(name,"w") : nullptr; - if (file) std::fprintf(file,"course,quality,debug,views,width,height,water_draws,normal_vertices,normal_ms,capture_ms,surface_ms,field_ms,frame_gpu_ms\n"); @@ -395,23 +390,468 @@ diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer + drawScenes.size(),normalVertices.size(),milliseconds[0],milliseconds[1],milliseconds[2],milliseconds[3],milliseconds[4],frameMilliseconds); std::fflush(csv); } -diff --git a/src/render/rt64_framebuffer_renderer.cpp b/src/render/rt64_framebuffer_renderer.cpp ---- a/src/render/rt64_framebuffer_renderer.cpp -+++ b/src/render/rt64_framebuffer_renderer.cpp -@@ -550,8 +550,10 @@ - if(pendingLava==UINT32_MAX)return; - finishWater(); -+ const auto lavaProfileToken=waterRenderer->beginProfile(worker,4); - if(waterRenderer->drawLavaLighting(worker,pendingLava,depthState,framebuffer.viewport)) { - worker->commandList->setFramebuffer(depthState ? fbStorage->colorWriteDepthRead.get() : fbStorage->colorDepthWrite.get()); - worker->commandList->drawInstanced(3,1,0,0); - } -+ waterRenderer->endProfile(worker,lavaProfileToken); - pendingLava=UINT32_MAX;pendingLavaProjection=UINT32_MAX; - switchToGraphicsPipeline(); -@@ -1360,4 +1362,5 @@ - if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordNormals(worker, *outputBuffers); - if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordField(worker); -+ if (waterRenderer && !waterRenderer->draws.empty()) waterRenderer->recordCaustics(worker); + } +diff --git a/src/render/rt64_water_renderer.h b/src/render/rt64_water_renderer.h +--- a/src/render/rt64_water_renderer.h ++++ b/src/render/rt64_water_renderer.h +@@ -95,8 +95,8 @@ struct LavaLightingSet : RenderDescriptorSetBase { + } + }; + struct WaterDescriptorSet : RenderDescriptorSetBase { +- uint32_t draws, sceneColor, sceneDepth, currentField, previousField, originalColor; +- explicit WaterDescriptorSet(RenderDevice *device = nullptr) { ++ uint32_t draws, sceneColor, sceneDepth, currentField, previousField, originalColor, caustics; ++ explicit WaterDescriptorSet(RenderDevice *device = nullptr, const RenderSampler *causticSampler = nullptr) { + builder.begin(); + draws = builder.addStructuredBuffer(1); + sceneColor = builder.addTexture(2); +@@ -104,6 +104,10 @@ struct WaterDescriptorSet : RenderDescriptorSetBase { + currentField = builder.addTexture(4); + previousField = builder.addTexture(5); + originalColor = builder.addTexture(6); ++ caustics = builder.addTexture(7); ++ // Immutable samplers go last: D3D12 drops them from the root table but ++ // still counts them in the set, so any later range would read empty. ++ builder.addImmutableSampler(8, causticSampler); + builder.end(); + if (device) create(device); + } +@@ -113,6 +117,15 @@ struct WaterDescriptorSet : RenderDescriptorSetBase { + struct WaterRenderer { + std::vector draws; + BufferPair drawBuffer; ++ // Declared before every descriptor set: the immutable sampler and the ++ // texture must outlive the sets that sample them. ++ std::unique_ptr causticSampler; ++ std::unique_ptr causticTexture; ++ std::unique_ptr causticView; ++ std::unique_ptr causticUpload; ++ std::vector causticOffsets; ++ uint32_t causticSize=0; ++ bool causticPending=false; + std::unique_ptr descriptorSet; + std::unique_ptr layout; + struct WaterfallSet : RenderDescriptorSetBase { +@@ -155,6 +168,7 @@ struct WaterRenderer { + BufferPair particleBuffer; + std::array sprayDistance{}; + std::array sprayHeading{}; ++ std::array lowPassDistance{}; + uint32_t spraySeed=1; + std::map> pipelines; + RenderVertexBufferView worldView; +@@ -238,7 +252,7 @@ struct WaterRenderer { + interop::WaterMaterial statsMaterial{}; + std::vector profileKinds; + uint32_t profileFrames=0; +- std::array profileTotals{}; ++ std::array profileTotals{}; + bool usesHDR; - if (vertexProcessor != nullptr) { + WaterRenderer(RenderDevice *device, RenderShaderFormat format, bool usesHDR,const ShaderLibrary *library); +@@ -258,6 +272,7 @@ struct WaterRenderer { + uint64_t allocationBytes() const; + void prepareField(RenderWorker *worker); + void recordField(RenderWorker *worker); ++ void recordCaustics(RenderWorker *worker); + void stepSpray(const interop::WaterMaterial &material, float previousTime, float nextTime, bool reset); + void beginSurface(RenderWorker *worker,uint32_t drawIndex); + void beginColor(RenderWorker *worker,uint32_t drawIndex); +diff --git a/src/shaders/RSPProcessCS.hlsl b/src/shaders/RSPProcessCS.hlsl +--- a/src/shaders/RSPProcessCS.hlsl ++++ b/src/shaders/RSPProcessCS.hlsl +@@ -17,6 +17,9 @@ struct RSPProcessCB { + float prevFrameWeight; + float curFrameWeight; + uint waterWorldActive; ++ float lavaGlowScale; ++ float lavaGlowCooling; ++ uint reserved; + }; + + [[vk::push_constant]] ConstantBuffer gConstants : register(b0); +@@ -42,6 +45,7 @@ RWStructuredBuffer dstPos : register(u19); + RWStructuredBuffer dstTc : register(u20); + RWStructuredBuffer dstCol : register(u21); + RWStructuredBuffer dstWorld : register(u22); ++Buffer srcLavaGlow : register(t23); + + [numthreads(GROUP_SIZE, 1, 1)] + void CSMain(uint vertexIndex : SV_DispatchThreadID) { +@@ -132,6 +136,11 @@ void CSMain(uint vertexIndex : SV_DispatchThreadID) { + const float4 screenPos = float4(ndcPos * rspViewport.scale + rspViewport.translate, tfPos.w); + dstPos[vertexOffsetIndex] = screenPos; + dstTc[vertexOffsetIndex] = tc; ++ if(gConstants.lavaGlowScale>0) { ++ const float energy=min(0.18,min(0.14,max(0,srcLavaGlow[vertexOffsetIndex]))*gConstants.lavaGlowScale); ++ const float3 warmth=lerp(float3(1,0.19,0.012),float3(1,0.07,0.006),saturate(gConstants.lavaGlowCooling)); ++ vertexColor.rgb=min(vertexColor.rgb+max(vertexColor.rgb,0.12)*warmth*energy,1); ++ } + dstCol[vertexOffsetIndex] = vertexColor; + if (gConstants.waterWorldActive) dstWorld[vertexOffsetIndex] = mul(worldMat, float4(pos - vel * (1.0f - gConstants.curFrameWeight), 1.0f)); + } +\ No newline at end of file +diff --git a/src/shaders/WaterFieldCS.hlsl b/src/shaders/WaterFieldCS.hlsl +--- a/src/shaders/WaterFieldCS.hlsl ++++ b/src/shaders/WaterFieldCS.hlsl +@@ -22,6 +22,7 @@ float segmentDistance(float2 p,float2 a,float2 b) { + groupshared float4 craftPath[WATER_CRAFT_COUNT]; + groupshared float4 craftMotion[WATER_CRAFT_COUNT]; + groupshared float4 craftResponse[WATER_CRAFT_COUNT]; ++groupshared float4 craftWash[WATER_CRAFT_COUNT]; + [numthreads(8,8,1)] + void CSMain(uint2 pixel : SV_DispatchThreadID, uint lane : SV_GroupIndex) { + // Zero-duration reset has no history, swept motion, or shoreline growth. +@@ -48,6 +49,11 @@ void CSMain(uint2 pixel : SV_DispatchThreadID, uint lane : SV_GroupIndex) { + craftMotion[lane]=float4(heading,distance/interval,min(length(to-from)/cell,2)*saturate(current.w*3)); + bool landed=!params.reset && old.w==0 && current.w>0 && params.simulation.y=m.animation.y; + craftResponse[lane]=float4(saturate((old.y-current.y)/interval/180),landed ? 1 : 0,current.w,0); ++ // Low pass: a footprint trailing beneath the craft, wider when higher. ++ float air=WATER_CRAFT_AIR(m,lane); ++ float clearance=abs(air); ++ float wash=current.w>=0 ? waterLowPassStrength(air)*saturate(distance/interval/WATER_LOWPASS_FULL_SPEED) : 0; ++ craftWash[lane]=float4(to-heading*(10+clearance*0.15),20+clearance*0.3,wash*min(length(to-from)/cell,2)); + } + GroupMemoryBarrierWithGroupSync(); + if (pixel.x>=params.resolution*WATER_CRAFT_COUNT || pixel.y>=params.resolution) return; +@@ -109,6 +115,19 @@ void CSMain(uint2 pixel : SV_DispatchThreadID, uint lane : SV_GroupIndex) { + foam += ring*0.65; + velocity += ring*0.7; + } ++ float4 wash=craftWash[i]; ++ if (wash.w>0) { ++ float2 offset=world-wash.xy; ++ float r=wash.z, behind=max(-dot(offset,heading),0); ++ float lateral=abs(dot(offset,float2(-heading.y,heading.x))); ++ // The moving pressure dent radiates its own waves; curtains spread ++ // wider than the hull wake; a churned trail follows the path. ++ float dent=exp(-dot(offset,offset)/(r*r)); ++ float curtain=exp(-pow((lateral-behind*0.55-r*0.35)/(6+r*0.25),2))*smoothstep(0,r*0.5,behind)*(1-smoothstep(r*2.5,r*4.5,behind)); ++ float churn=exp(-pow(d/(r*0.5),2)); ++ foam += (churn*0.035+curtain*0.03)*wash.w; ++ velocity += (curtain*0.05-dent*0.12)*wash.w; ++ } + } + if (m.craftCurrent[tile].w<0) { height=0;velocity=0;foam=0;shore=0; } + nextField[pixel]=float4(clamp(height,-1,1),clamp(velocity,-4,4),saturate(foam),saturate(shore)); +diff --git a/src/shaders/WaterPS.hlsl b/src/shaders/WaterPS.hlsl +--- a/src/shaders/WaterPS.hlsl ++++ b/src/shaders/WaterPS.hlsl +@@ -7,6 +7,8 @@ Texture2D sceneDepth : register(t3); + Texture2D currentField : register(t4); + Texture2D previousField : register(t5); + Texture2D originalColor : register(t6); ++Texture2D causticTexture : register(t7); ++SamplerState causticSampler : register(s8); + + static const float PI = 3.14159265359; + +@@ -249,12 +251,16 @@ float4 PSMain(float4 position : SV_POSITION, float3 worldPosition : TEXCOORD0, + float3 V=normalize(p.camera.xyz-worldPosition); + float footprint=max(length(ddx(worldPosition.xz)),length(ddy(worldPosition.xz)))*LAVA_WORLD_SCALE; + float4 molten=lavaSurface(worldPosition.xz,p.camera.w,footprint); ++ // Surface heat and nearby glow share the interpolated clock. Pulse ++ // between hot and cooling palettes, never between lit and black. ++ const float cooling=saturate(m.sunColor.w)*0.5*(1-sin(p.camera.w*m.optics.y*1.7)); ++ molten.rgb=lavaHeatPalette(molten.rgb,cooling)*m.shoreCounts[0].z; + float roughness=clamp(m.shallowColor.a+molten.a*0.18,0.065,0.65); + float4 reflection=0; + if(m.appearance.x>0) { + #ifdef HARDWARE_WATER + bool fallingHit=false,capturedHit=false; +- float4 traced=hardwareWaterReflection(worldPosition,N,V,p,fallingHit,capturedHit); ++ float4 traced=m.shoreCounts[0].y>0.5 ? hardwareWaterReflection(worldPosition,N,V,p,fallingHit,capturedHit) : float4(0,0,0,0); + if(fallingHit || capturedHit)reflection=traced; + else { + reflection=screenReflection(worldPosition,N,V,roughness,p); +@@ -295,6 +301,38 @@ float4 PSMain(float4 position : SV_POSITION, float3 worldPosition : TEXCOORD0, + float determinant=dx.x*dy.y-dx.y*dy.x; + float2 wakeGradient=float2(ddx(wake.x)*dy.y-ddy(wake.x)*dx.y,ddy(wake.x)*dx.x-ddx(wake.x)*dy.x)/max(abs(determinant),0.001)*sign(determinant); + ripples += clamp(wakeGradient*8,-0.10,0.10); ++ // Low pass: downwash and hovercraft skirts roughen and whiten a teardrop ++ // of liquid beneath and behind each craft. Analytic, so it never lags. ++ float churn=0,churnFoam=0; ++ float2 churnHeading=0; ++ { ++ float interval=max(m.animation.y-m.animation.x,0.00001); ++ float f=saturate((p.camera.w-m.animation.x)/interval); ++ [loop] for (uint i=0;i0 ? radius*0.7 : radius*3.5; ++ float footprint=exp(-pow(lateral/radius,2)-pow(ahead/extent,2))*wash; ++ if (footprint>churn) { churn=footprint; churnHeading=heading; } ++ } ++ } ++ if (churn>0.001) { ++ // World-anchored streaks along the heading: the craft sweeps over them. ++ float2 across=float2(-churnHeading.y,churnHeading.x); ++ float2 streakAt=float2(dot(worldPosition.xz,across)*0.16,dot(worldPosition.xz,churnHeading)*0.012); ++ float s0=foamNoise(streakAt); ++ float2 slope=across*(foamNoise(streakAt+float2(0.35,0))-s0)*0.5+churnHeading*(foamNoise(streakAt+float2(0,0.03))-s0)*0.2; ++ ripples += clamp(slope*churn*0.6,-0.12,0.12); ++ churnFoam=churn*(0.25+0.75*smoothstep(0.35,0.8,s0))*0.35; ++ } + float3 N = normalize(geometric + float3(-ripples.x, 0, -ripples.y)); + float3 V = normalize(p.camera.xyz - worldPosition); + float3 L = normalize(m.sunDirection.xyz); +@@ -304,7 +342,7 @@ float4 PSMain(float4 position : SV_POSITION, float3 worldPosition : TEXCOORD0, + float nh = saturate(dot(N,H)); + float vh = saturate(dot(V,H)); + float fresnel = 0.0204 + 0.9796 * pow(1-nv, 5); +- float roughness = clamp(m.shallowColor.a + length(float2(ddx(N.x), ddy(N.z))) * 0.2, 0.065, 0.65); ++ float roughness = clamp(m.shallowColor.a + length(float2(ddx(N.x), ddy(N.z))) * 0.2 + churn * 0.25, 0.065, 0.65); + float a2 = pow(roughness, 4); + float d = a2 / max(PI * pow(nh*nh*(a2-1)+1, 2), 0.00001); + float k = pow(roughness + 1, 2) * 0.125; +@@ -318,16 +356,18 @@ float4 PSMain(float4 position : SV_POSITION, float3 worldPosition : TEXCOORD0, + float refractedThickness = opticalDepth(refractedPixel,position.z,worldPosition,p); + // Reject foreground silhouettes at the proposed sample before distorting. + if (refractedThickness < 0) { refractedPixel = position.xy; refractedThickness = thickness; } +- // Derivatives must be evaluated for the whole pixel quad, before the +- // depth-validity branch. A branch crossing a shoreline otherwise leaves +- // caustic receiver normals/filter widths undefined on some backends. +- float bottomDepth=sceneDepth.Load(int3(int2(boundPixel(refractedPixel,p)),0)); +- float3 bottom=waterWorldFromPixel(refractedPixel,bottomDepth,p); +- float3 bottomNormal=cross(ddx(bottom),ddy(bottom)); +- bottomNormal*=rsqrt(max(dot(bottomNormal,bottomNormal),0.000001)); +- if (bottomNormal.y<0) bottomNormal=-bottomNormal; +- float2 causticUV=bottom.xz*0.035; +- float causticFilter=1-smoothstep(0.25,1.5,length(fwidth(causticUV))); ++ // Caustics follow the reference water workflow: one authored pattern read ++ // twice on the water plane, panned in opposite directions, the second ++ // layer rescaled so the two never align. Mipmaps keep distant cells from ++ // breaking into stair-stepped lines. Sample before the depth-validity ++ // branch: a branch crossing a shoreline leaves derivatives undefined. ++ float2 causticUV=worldPosition.xz/130+ripples*0.6; ++ float caustic=causticTexture.Sample(causticSampler,causticUV+float2(-0.05,0.05)*p.camera.w) ++ +causticTexture.Sample(causticSampler,(causticUV+float2(0.05,-0.05)*p.camera.w)*0.66); ++ // Vertical water column under this pixel. Unlike the view-ray thickness it ++ // does not grow at grazing angles. Negative without a submerged surface. ++ float waterDepth=thickness>=0 ? max(worldPosition.y-waterWorldFromPixel(position.xy, ++ sceneDepth.Load(int3(int2(position.xy),0)),p).y,0) : -1; + if (thickness >= 0 && refractedThickness >= 0) { + float3 transmission = exp(-m.deepColor.a * float3(1.8,0.55,0.30) * refractedThickness); + // Course geometry often ends a short distance below the shoreline. +@@ -341,15 +381,12 @@ float4 PSMain(float4 position : SV_POSITION, float3 worldPosition : TEXCOORD0, + // Uncovered taps transition to the water body, not an unrelated + // background color. Keep fractional coverage for a soft boundary. + transmission*=receiverColor.a; +- // Stable world-space light focusing, confined to an actual shallow +- // submerged receiver. Dark source lighting and steep walls reject it. +- float phaseA=causticUV.x+sin(causticUV.y*1.3+p.camera.w*0.65); +- float phaseB=causticUV.y-sin(causticUV.x*1.1-p.camera.w*0.5); +- float focusing=pow(saturate(1-abs(sin(phaseA)+sin(phaseB)*0.55)),12); +- float receiver=smoothstep(0.2,0.65,bottomNormal.y)*saturate(dot(bottomNormal,L)); ++ // Focused light only in shallow water and near the camera, and never ++ // on receivers whose source lighting is dark. ++ float receiver=smoothstep(0,2,waterDepth)*(1-smoothstep(0,60,waterDepth)); ++ receiver*=1-smoothstep(900,2200,length(p.camera.xyz-worldPosition)); + receiver*=smoothstep(0.015,0.08,dot(bottomColor,float3(0.2126,0.7152,0.0722))); +- receiver*=bottom.y0.001) { ++ float spark=smoothstep(0.93,1.0,foamHash(floor(glitterAt)+floor(p.camera.w*12)*float2(7.13,3.71))); ++ color+=m.sunColor.rgb*m.sunDirection.a*spark*pow(saturate(dot(reflect(-V,N),L)),4)*churn*glitterFilter*1.2; ++ } + if (p.fogColor.a > 0.5) color = lerp(color, p.fogColor.rgb, saturate(sourceColor.a)); + // The original mirrored scene has already passed through course fog. + // Compose it here to avoid applying that fog a second time and erasing +diff --git a/src/shaders/WaterSprayPS.hlsl b/src/shaders/WaterSprayPS.hlsl +--- a/src/shaders/WaterSprayPS.hlsl ++++ b/src/shaders/WaterSprayPS.hlsl +@@ -8,10 +8,14 @@ Texture2DMS waterDepth : register(t4); + Texture2D waterDepth : register(t4); + #endif + float4 PSMain(float4 position : SV_POSITION, float2 uv : TEXCOORD0, +- float3 world : TEXCOORD1, float2 fade : TEXCOORD2) : SV_TARGET0 { ++ float3 world : TEXCOORD1, float4 fade : TEXCOORD2) : SV_TARGET0 { + WaterDrawParams p=waterDraws[gConstants.drawIndex]; + float radius=dot(uv,uv); +- float alpha=(1-smoothstep(0.10,1,radius))*fade.x; ++ bool mist=fade.z>0.5 && fade.z<1.5; ++ // Streaks are brightest at the head (uv.y=+1) and fade toward the tail. ++ float streak=(1-smoothstep(0.15,1,abs(uv.x)))*smoothstep(-1,0.3,uv.y)*(1-smoothstep(0.75,1,uv.y)); ++ float alpha=(mist ? exp(-radius*2.2)*(1-smoothstep(0.6,1,radius)) : fade.z>1.5 ? streak : 1-smoothstep(0.10,1,radius))*fade.x; ++ float soft=mist ? 40 : 12; + int2 pixel=int2(clamp(position.xy,p.viewBounds.xy,p.viewBounds.zw-1)); + float water=0; + #ifdef SPRAY_MSAA +@@ -23,13 +27,21 @@ float4 PSMain(float4 position : SV_POSITION, float2 uv : TEXCOORD0, + #else + water=waterDepth.Load(int3(pixel,0)); + #endif +- if (water>0) alpha*=saturate((water-fade.y)/12); ++ if (water>0) alpha*=saturate((water-fade.y)/soft); + float depth=sceneDepth.Load(int3(pixel,0)); + if (depth<0.9999) { + float3 background=waterWorldFromPixel(position.xy,depth,p); +- alpha*=saturate((length(background-p.camera.xyz)-fade.y)/12); ++ alpha*=saturate((length(background-p.camera.xyz)-fade.y)/soft); + } + float3 light=(p.material.skyColor.rgb*0.3+p.material.sunColor.rgb*0.7)*p.material.sunColor.a; ++ if (fade.z>0.5) { ++ // Low-pass spray is lit white water, glowing faintly against the sun. ++ light=max(light,float3(0.62,0.66,0.68)*p.material.sunColor.a*2); ++ float scatter=pow(saturate(dot(normalize(world-p.camera.xyz),normalize(p.material.sunDirection.xyz))),6); ++ light+=p.material.sunColor.rgb*scatter*(mist ? 0.55 : 0.35); ++ // A few droplets catch the sun as glints. ++ if (fade.z>1.5 && fade.w>0.85) light+=p.material.sunColor.rgb*(0.25+scatter); ++ } + // Match the course haze without introducing a second exposure system. + float fog=p.fogColor.a*saturate((fade.y-1200)/5000); + light=lerp(light,p.fogColor.rgb,fog); +diff --git a/src/shaders/WaterSprayVS.hlsl b/src/shaders/WaterSprayVS.hlsl +--- a/src/shaders/WaterSprayVS.hlsl ++++ b/src/shaders/WaterSprayVS.hlsl +@@ -5,7 +5,7 @@ StructuredBuffer particles : register(t2); + + void VSMain(uint vertex : SV_VertexID, uint instance : SV_InstanceID, + out float4 position : SV_POSITION, out float2 uv : TEXCOORD0, +- out float3 world : TEXCOORD1, out float2 fade : TEXCOORD2) { ++ out float3 world : TEXCOORD1, out float4 fade : TEXCOORD2) { + // Return a finite, degenerate offscreen primitive for invisible particles. + // Alpha zero alone cannot make invalid projected positions safe. + position=float4(2,2,2,1); uv=0; world=0; fade=0; +@@ -13,11 +13,16 @@ void VSMain(uint vertex : SV_VertexID, uint instance : SV_InstanceID, + WaterSprayParticle particle=particles[instance]; + float age=p.camera.w-particle.origin.w; + if (age<0 || age>=particle.velocity.w || particle.velocity.w<=0) return; +- float drag=1.5, response=(1-exp(-drag*age))/drag; ++ // Kind 0 is hull spray, 1 low-pass mist, 2 low-pass curtain droplets. ++ float kind=particle.appearance.w; ++ bool mist=kind>0.5 && kind<1.5, streak=kind>1.5; ++ float life=saturate(age/particle.velocity.w); ++ float drag=mist ? 2.6 : 1.5, gravity=mist ? 12 : 140, response=(1-exp(-drag*age))/drag; + world=particle.origin.xyz+particle.velocity.xyz*response; +- world.y-=140*(age-response)/drag; ++ world.y-=gravity*(age-response)/drag; ++ float size=particle.appearance.x*(mist ? 1+life*1.2 : 1); + float4 clip=mul(p.viewProjection,float4(world,1)); +- float4 top=mul(p.viewProjection,float4(world+float3(0,particle.appearance.x,0),1)); ++ float4 top=mul(p.viewProjection,float4(world+float3(0,size,0),1)); + // Both points must be in front of the eye before either perspective divide. + // A top edge crossing the eye plane used to create an enormous white quad. + if (!all(isfinite(clip)) || !all(isfinite(top)) || clip.w<=0.01 || top.w<=0.01) return; +@@ -29,11 +34,32 @@ void VSMain(uint vertex : SV_VertexID, uint instance : SV_InstanceID, + const float2 corners[6]={float2(-1,-1),float2(1,-1),float2(-1,1),float2(-1,1),float2(1,-1),float2(1,1)}; + uv=corners[vertex]; + position=float4(xy*clip.w,screen.z*clip.w,clip.w); +- float radius=clamp(projectedRadius,0.0005,0.035); +- position.xy+=uv*radius*float2(p.screen.y/p.screen.x,1)*clip.w; +- float life=saturate(age/particle.velocity.w); ++ // Streaks keep a minimum width so fine droplets stay visible. ++ float radius=clamp(projectedRadius,streak ? 0.0022 : 0.0005,mist ? 0.24 : streak ? 0.12 : 0.035); ++ float2 aspect=float2(p.screen.y/p.screen.x,1); ++ float2 offset=uv*radius*aspect; ++ float energy=1; ++ if (streak) { ++ // Stretch curtain droplets along their recent screen motion. ++ float decay=exp(-drag*age); ++ float3 velocity=particle.velocity.xyz*decay-float3(0,gravity*(1-decay)/drag,0); ++ float4 tail=mul(p.viewProjection,float4(world-velocity*0.09,1)); ++ if (all(isfinite(tail)) && tail.w>0.01) { ++ float3 tailScreen=tail.xyz/tail.w*float3(1,-1,1)*p.viewportScale.xyz+p.viewportTranslate.xyz; ++ float2 tailXY=(tailScreen.xy/p.screen.xy*float2(2,-2)+float2(-1,1))*p.screenTransform.xy+p.screenTransform.zw; ++ float2 trail=(xy-tailXY)/aspect; ++ float span=length(trail); ++ if (all(isfinite(trail)) && span>radius*0.5) { ++ float2 along=trail/span, across=float2(-along.y,along.x); ++ span=min(span,0.2); ++ offset=(across*uv.x*radius+along*(uv.y*(span*0.5+radius)-span*0.5))*aspect; ++ energy=pow(saturate(2*radius/(span+2*radius)),0.3); ++ } ++ } ++ } ++ position.xy+=offset*clip.w; + float distance=length(world-p.camera.xyz); +- float nearFade=smoothstep(particle.appearance.x*4,particle.appearance.x*12,distance); +- float sizeFade=1-smoothstep(0.012,0.035,projectedRadius); +- fade=float2(particle.appearance.y*smoothstep(0,0.07,age)*(1-smoothstep(0.55,1,life))*nearFade*sizeFade,distance); ++ float nearFade=mist ? smoothstep(size*1.5,size*4,distance) : smoothstep(particle.appearance.x*4,particle.appearance.x*12,distance); ++ float sizeFade=mist ? 1-smoothstep(0.14,0.24,projectedRadius) : streak ? 1-smoothstep(0.07,0.12,projectedRadius) : 1-smoothstep(0.012,0.035,projectedRadius); ++ fade=float4(particle.appearance.y*smoothstep(0,0.07,age)*(1-smoothstep(0.55,1,life))*nearFade*sizeFade*energy,distance,kind,particle.appearance.z); + } +diff --git a/src/shared/rt64_water_params.h b/src/shared/rt64_water_params.h +--- a/src/shared/rt64_water_params.h ++++ b/src/shared/rt64_water_params.h +@@ -2,6 +2,14 @@ + #include "rt64_hlsl.h" + #define WATER_CRAFT_COUNT 8 + #define WATER_SHORE_SEGMENTS_PER_TILE 8 ++// Low passes: clearance above the liquid at which a skimming craft still ++// disturbs it, full-strength clearance, and the speed for full spray. ++#define WATER_LOWPASS_PLANE_CEILING 300.0f ++#define WATER_LOWPASS_PLANE_FULL 30.0f ++#define WATER_LOWPASS_HOVER_CEILING 120.0f ++#define WATER_LOWPASS_HOVER_FULL 20.0f ++#define WATER_LOWPASS_HOVER_SCALE 0.55f ++#define WATER_LOWPASS_FULL_SPEED 900.0f + #define WATER_STYLE_DKR (1U << 8U) + #define WATER_DEBUG_MASK 255U + +@@ -22,6 +30,7 @@ struct WaterMaterial { + float4 craftPrevious[WATER_CRAFT_COUNT]; // world XYZ, wet contact fraction (-1 means inactive) + float4 craftCurrent[WATER_CRAFT_COUNT]; + float4 shoreCounts[2]; // bounded static shoreline segments in each craft's world tile ++ // Water draws carry low-pass clearance in the last two shoreline vectors (WATER_CRAFT_AIR). + float4 shoreSegments[WATER_CRAFT_COUNT * WATER_SHORE_SEGMENTS_PER_TILE]; // immutable mean-water intersections, XZ endpoints + float4 performance; // SSR steps, wake resolution, hardware distance, spray tier + float4 appearance; // reflection, refraction, wake and waterfall strengths +@@ -32,6 +41,17 @@ struct WaterMaterial { + float4 cameraPosition; // XYZ, W=1 marks the reviewed DKR adapter + }; + ++// Low-pass clearance above the liquid, four crafts per vector: positive ++// values are aircraft, negative values hovercraft, zero means no low pass. ++// WaterDrawParams is exactly at the 2048-byte structured-buffer stride, so ++// the clearance shares the last two shoreline vectors instead of growing it. ++// Water draws publish no shoreline segments (every shoreCounts entry stays ++// zero, so the field shaders never read them) and lava draws reuse only ++// shoreSegments[0..15] for emitters and write no clearance. Low passes are ++// gated on active crafts (W>=0), which lava and vertical water never have. ++#define WATER_CRAFT_AIR_SEGMENT (WATER_CRAFT_COUNT * WATER_SHORE_SEGMENTS_PER_TILE - WATER_CRAFT_COUNT / 4) ++#define WATER_CRAFT_AIR(m, i) ((m).shoreSegments[WATER_CRAFT_AIR_SEGMENT + (i) / 4][(i) % 4]) ++ + struct WaterDrawParams { + WaterMaterial material; + float4 screen; // original framebuffer width/height, target width/height +@@ -68,7 +88,17 @@ struct WaterFieldPushConstants { + static_assert(sizeof(WaterMaterial) == 1584, "DKR water task ABI"); + static_assert(sizeof(WaterDrawParams) == 2048, "DKR water shader ABI"); + static_assert(sizeof(WaterDrawParams) <= 2048, "D3D structured-buffer stride limit"); ++static_assert(WATER_CRAFT_AIR_SEGMENT >= 16, "Low-pass clearance must not overlap the lava emitters"); + static_assert(sizeof(WaterFieldPushConstants) == 32, "Portable water field shader ABI"); + static_assert(sizeof(WaterFieldPushConstants) <= 128, "Vulkan guaranteed push-constant budget"); + } + #endif ++ ++#ifndef HLSL_CPU ++// Matches lowPassStrength in rt64_water_renderer.cpp. ++float waterLowPassStrength(float air) { ++ if (air>0) return 1-smoothstep(WATER_LOWPASS_PLANE_FULL,WATER_LOWPASS_PLANE_CEILING,air); ++ if (air<0) return WATER_LOWPASS_HOVER_SCALE*(1-smoothstep(WATER_LOWPASS_HOVER_FULL,WATER_LOWPASS_HOVER_CEILING,-air)); ++ return 0; ++} ++#endif diff --git a/patches/enhanced-water/dkr-lowpass.patch b/patches/enhanced-water/dkr-lowpass.patch deleted file mode 100644 index 36074e86..00000000 --- a/patches/enhanced-water/dkr-lowpass.patch +++ /dev/null @@ -1,382 +0,0 @@ -diff --git a/src/shared/rt64_water_params.h b/src/shared/rt64_water_params.h ---- a/src/shared/rt64_water_params.h -+++ b/src/shared/rt64_water_params.h -@@ -2,6 +2,14 @@ - #include "rt64_hlsl.h" - #define WATER_CRAFT_COUNT 8 - #define WATER_SHORE_SEGMENTS_PER_TILE 8 -+// Low passes: clearance above the liquid at which a skimming craft still -+// disturbs it, full-strength clearance, and the speed for full spray. -+#define WATER_LOWPASS_PLANE_CEILING 300.0f -+#define WATER_LOWPASS_PLANE_FULL 30.0f -+#define WATER_LOWPASS_HOVER_CEILING 120.0f -+#define WATER_LOWPASS_HOVER_FULL 20.0f -+#define WATER_LOWPASS_HOVER_SCALE 0.55f -+#define WATER_LOWPASS_FULL_SPEED 900.0f - #define WATER_STYLE_DKR (1U << 8U) - #define WATER_DEBUG_MASK 255U - -@@ -22,6 +30,7 @@ - float4 craftPrevious[WATER_CRAFT_COUNT]; // world XYZ, wet contact fraction (-1 means inactive) - float4 craftCurrent[WATER_CRAFT_COUNT]; - float4 shoreCounts[2]; // bounded static shoreline segments in each craft's world tile -+ // Water draws carry low-pass clearance in the last two shoreline vectors (WATER_CRAFT_AIR). - float4 shoreSegments[WATER_CRAFT_COUNT * WATER_SHORE_SEGMENTS_PER_TILE]; // immutable mean-water intersections, XZ endpoints - float4 performance; // SSR steps, wake resolution, hardware distance, spray tier - float4 appearance; // reflection, refraction, wake and waterfall strengths -@@ -31,6 +40,17 @@ - float4x4 cameraViewProjection; - float4 cameraPosition; // XYZ, W=1 marks the reviewed DKR adapter - }; -+ -+// Low-pass clearance above the liquid, four crafts per vector: positive -+// values are aircraft, negative values hovercraft, zero means no low pass. -+// WaterDrawParams is exactly at the 2048-byte structured-buffer stride, so -+// the clearance shares the last two shoreline vectors instead of growing it. -+// Water draws publish no shoreline segments (every shoreCounts entry stays -+// zero, so the field shaders never read them) and lava draws reuse only -+// shoreSegments[0..15] for emitters and write no clearance. Low passes are -+// gated on active crafts (W>=0), which lava and vertical water never have. -+#define WATER_CRAFT_AIR_SEGMENT (WATER_CRAFT_COUNT * WATER_SHORE_SEGMENTS_PER_TILE - WATER_CRAFT_COUNT / 4) -+#define WATER_CRAFT_AIR(m, i) ((m).shoreSegments[WATER_CRAFT_AIR_SEGMENT + (i) / 4][(i) % 4]) - - struct WaterDrawParams { - WaterMaterial material; -@@ -68,7 +88,17 @@ - static_assert(sizeof(WaterMaterial) == 1584, "DKR water task ABI"); - static_assert(sizeof(WaterDrawParams) == 2048, "DKR water shader ABI"); - static_assert(sizeof(WaterDrawParams) <= 2048, "D3D structured-buffer stride limit"); -+static_assert(WATER_CRAFT_AIR_SEGMENT >= 16, "Low-pass clearance must not overlap the lava emitters"); - static_assert(sizeof(WaterFieldPushConstants) == 32, "Portable water field shader ABI"); - static_assert(sizeof(WaterFieldPushConstants) <= 128, "Vulkan guaranteed push-constant budget"); - } - #endif -+ -+#ifndef HLSL_CPU -+// Matches lowPassStrength in rt64_water_renderer.cpp. -+float waterLowPassStrength(float air) { -+ if (air>0) return 1-smoothstep(WATER_LOWPASS_PLANE_FULL,WATER_LOWPASS_PLANE_CEILING,air); -+ if (air<0) return WATER_LOWPASS_HOVER_SCALE*(1-smoothstep(WATER_LOWPASS_HOVER_FULL,WATER_LOWPASS_HOVER_CEILING,-air)); -+ return 0; -+} -+#endif -diff --git a/src/render/rt64_water_renderer.h b/src/render/rt64_water_renderer.h ---- a/src/render/rt64_water_renderer.h -+++ b/src/render/rt64_water_renderer.h -@@ -168,6 +168,7 @@ - BufferPair particleBuffer; - std::array sprayDistance{}; - std::array sprayHeading{}; -+ std::array lowPassDistance{}; - uint32_t spraySeed=1; - std::map> pipelines; - RenderVertexBufferView worldView; -diff --git a/src/render/rt64_water_renderer.cpp b/src/render/rt64_water_renderer.cpp ---- a/src/render/rt64_water_renderer.cpp -+++ b/src/render/rt64_water_renderer.cpp -@@ -549,9 +549,20 @@ - worker->commandList->setFramebuffer(scene.waterColorFramebuffer.get()); - } - -+// Matches waterLowPassStrength in rt64_water_params.h. -+static float lowPassStrength(float air) { -+ const auto fade=[](float full,float ceiling,float clearance) { -+ const float t=std::clamp((clearance-full)/(ceiling-full),0.0f,1.0f); -+ return 1-t*t*(3-2*t); -+ }; -+ if (air>0) return fade(WATER_LOWPASS_PLANE_FULL,WATER_LOWPASS_PLANE_CEILING,air); -+ if (air<0) return WATER_LOWPASS_HOVER_SCALE*fade(WATER_LOWPASS_HOVER_FULL,WATER_LOWPASS_HOVER_CEILING,-air); -+ return 0; -+} -+ - void WaterRenderer::stepSpray(const interop::WaterMaterial &m,float fromTime,float toTime,bool reset) { - if (reset || m.identity.x<2 || m.effects.x<0.5f) { -- particles.clear(); sprayDistance.fill(0); sprayHeading.fill({0,0,0,0}); -+ particles.clear(); sprayDistance.fill(0); sprayHeading.fill({0,0,0,0}); lowPassDistance.fill(0); - spraySeed=0x57A064u^uint32_t(m.identity.w); - return; - } -@@ -587,6 +598,45 @@ - particles.push_back(p); - } - } -+ // Low passes lift spray from the liquid beneath the craft, not from its -+ // hull: outward curtains (kind 2) and slowly settling mist (kind 1). -+ for (uint32_t i=0;i0;}); -+ // A low pass over the liquid keeps the field alive without hull contact. -+ bool lowPass=false; -+ for (uint32_t i=0;i=0 && WATER_CRAFT_AIR(m,i)!=0; -+ const bool active=horizontal!=draws.end() && (lowPass || -+ std::any_of(std::begin(m.craftCurrent),std::end(m.craftCurrent),[](const auto& c){return c.w>0;})); - const uint32_t resolution=active && m.performance.y>=128 ? uint32_t(m.performance.y) : water_work::fieldResolution(active,bool(fieldTextures[0]),fieldResolution,uint32_t(m.identity.x)); - constexpr float extent=4096, dt=1.0f/60; - if (fieldResolution!=resolution) { -diff --git a/src/shaders/WaterFieldCS.hlsl b/src/shaders/WaterFieldCS.hlsl ---- a/src/shaders/WaterFieldCS.hlsl -+++ b/src/shaders/WaterFieldCS.hlsl -@@ -22,6 +22,7 @@ - groupshared float4 craftPath[WATER_CRAFT_COUNT]; - groupshared float4 craftMotion[WATER_CRAFT_COUNT]; - groupshared float4 craftResponse[WATER_CRAFT_COUNT]; -+groupshared float4 craftWash[WATER_CRAFT_COUNT]; - [numthreads(8,8,1)] - void CSMain(uint2 pixel : SV_DispatchThreadID, uint lane : SV_GroupIndex) { - // Zero-duration reset has no history, swept motion, or shoreline growth. -@@ -48,6 +49,11 @@ - craftMotion[lane]=float4(heading,distance/interval,min(length(to-from)/cell,2)*saturate(current.w*3)); - bool landed=!params.reset && old.w==0 && current.w>0 && params.simulation.y=m.animation.y; - craftResponse[lane]=float4(saturate((old.y-current.y)/interval/180),landed ? 1 : 0,current.w,0); -+ // Low pass: a footprint trailing beneath the craft, wider when higher. -+ float air=WATER_CRAFT_AIR(m,lane); -+ float clearance=abs(air); -+ float wash=current.w>=0 ? waterLowPassStrength(air)*saturate(distance/interval/WATER_LOWPASS_FULL_SPEED) : 0; -+ craftWash[lane]=float4(to-heading*(10+clearance*0.15),20+clearance*0.3,wash*min(length(to-from)/cell,2)); - } - GroupMemoryBarrierWithGroupSync(); - if (pixel.x>=params.resolution*WATER_CRAFT_COUNT || pixel.y>=params.resolution) return; -@@ -109,6 +115,19 @@ - foam += ring*0.65; - velocity += ring*0.7; - } -+ float4 wash=craftWash[i]; -+ if (wash.w>0) { -+ float2 offset=world-wash.xy; -+ float r=wash.z, behind=max(-dot(offset,heading),0); -+ float lateral=abs(dot(offset,float2(-heading.y,heading.x))); -+ // The moving pressure dent radiates its own waves; curtains spread -+ // wider than the hull wake; a churned trail follows the path. -+ float dent=exp(-dot(offset,offset)/(r*r)); -+ float curtain=exp(-pow((lateral-behind*0.55-r*0.35)/(6+r*0.25),2))*smoothstep(0,r*0.5,behind)*(1-smoothstep(r*2.5,r*4.5,behind)); -+ float churn=exp(-pow(d/(r*0.5),2)); -+ foam += (churn*0.035+curtain*0.03)*wash.w; -+ velocity += (curtain*0.05-dent*0.12)*wash.w; -+ } - } - if (m.craftCurrent[tile].w<0) { height=0;velocity=0;foam=0;shore=0; } - nextField[pixel]=float4(clamp(height,-1,1),clamp(velocity,-4,4),saturate(foam),saturate(shore)); -diff --git a/src/shaders/WaterSprayVS.hlsl b/src/shaders/WaterSprayVS.hlsl ---- a/src/shaders/WaterSprayVS.hlsl -+++ b/src/shaders/WaterSprayVS.hlsl -@@ -5,7 +5,7 @@ - - void VSMain(uint vertex : SV_VertexID, uint instance : SV_InstanceID, - out float4 position : SV_POSITION, out float2 uv : TEXCOORD0, -- out float3 world : TEXCOORD1, out float2 fade : TEXCOORD2) { -+ out float3 world : TEXCOORD1, out float4 fade : TEXCOORD2) { - // Return a finite, degenerate offscreen primitive for invisible particles. - // Alpha zero alone cannot make invalid projected positions safe. - position=float4(2,2,2,1); uv=0; world=0; fade=0; -@@ -13,11 +13,16 @@ - WaterSprayParticle particle=particles[instance]; - float age=p.camera.w-particle.origin.w; - if (age<0 || age>=particle.velocity.w || particle.velocity.w<=0) return; -- float drag=1.5, response=(1-exp(-drag*age))/drag; -+ // Kind 0 is hull spray, 1 low-pass mist, 2 low-pass curtain droplets. -+ float kind=particle.appearance.w; -+ bool mist=kind>0.5 && kind<1.5, streak=kind>1.5; -+ float life=saturate(age/particle.velocity.w); -+ float drag=mist ? 2.6 : 1.5, gravity=mist ? 12 : 140, response=(1-exp(-drag*age))/drag; - world=particle.origin.xyz+particle.velocity.xyz*response; -- world.y-=140*(age-response)/drag; -+ world.y-=gravity*(age-response)/drag; -+ float size=particle.appearance.x*(mist ? 1+life*1.2 : 1); - float4 clip=mul(p.viewProjection,float4(world,1)); -- float4 top=mul(p.viewProjection,float4(world+float3(0,particle.appearance.x,0),1)); -+ float4 top=mul(p.viewProjection,float4(world+float3(0,size,0),1)); - // Both points must be in front of the eye before either perspective divide. - // A top edge crossing the eye plane used to create an enormous white quad. - if (!all(isfinite(clip)) || !all(isfinite(top)) || clip.w<=0.01 || top.w<=0.01) return; -@@ -29,11 +34,32 @@ - const float2 corners[6]={float2(-1,-1),float2(1,-1),float2(-1,1),float2(-1,1),float2(1,-1),float2(1,1)}; - uv=corners[vertex]; - position=float4(xy*clip.w,screen.z*clip.w,clip.w); -- float radius=clamp(projectedRadius,0.0005,0.035); -- position.xy+=uv*radius*float2(p.screen.y/p.screen.x,1)*clip.w; -- float life=saturate(age/particle.velocity.w); -+ // Streaks keep a minimum width so fine droplets stay visible. -+ float radius=clamp(projectedRadius,streak ? 0.0022 : 0.0005,mist ? 0.24 : streak ? 0.12 : 0.035); -+ float2 aspect=float2(p.screen.y/p.screen.x,1); -+ float2 offset=uv*radius*aspect; -+ float energy=1; -+ if (streak) { -+ // Stretch curtain droplets along their recent screen motion. -+ float decay=exp(-drag*age); -+ float3 velocity=particle.velocity.xyz*decay-float3(0,gravity*(1-decay)/drag,0); -+ float4 tail=mul(p.viewProjection,float4(world-velocity*0.09,1)); -+ if (all(isfinite(tail)) && tail.w>0.01) { -+ float3 tailScreen=tail.xyz/tail.w*float3(1,-1,1)*p.viewportScale.xyz+p.viewportTranslate.xyz; -+ float2 tailXY=(tailScreen.xy/p.screen.xy*float2(2,-2)+float2(-1,1))*p.screenTransform.xy+p.screenTransform.zw; -+ float2 trail=(xy-tailXY)/aspect; -+ float span=length(trail); -+ if (all(isfinite(trail)) && span>radius*0.5) { -+ float2 along=trail/span, across=float2(-along.y,along.x); -+ span=min(span,0.2); -+ offset=(across*uv.x*radius+along*(uv.y*(span*0.5+radius)-span*0.5))*aspect; -+ energy=pow(saturate(2*radius/(span+2*radius)),0.3); -+ } -+ } -+ } -+ position.xy+=offset*clip.w; - float distance=length(world-p.camera.xyz); -- float nearFade=smoothstep(particle.appearance.x*4,particle.appearance.x*12,distance); -- float sizeFade=1-smoothstep(0.012,0.035,projectedRadius); -- fade=float2(particle.appearance.y*smoothstep(0,0.07,age)*(1-smoothstep(0.55,1,life))*nearFade*sizeFade,distance); -+ float nearFade=mist ? smoothstep(size*1.5,size*4,distance) : smoothstep(particle.appearance.x*4,particle.appearance.x*12,distance); -+ float sizeFade=mist ? 1-smoothstep(0.14,0.24,projectedRadius) : streak ? 1-smoothstep(0.07,0.12,projectedRadius) : 1-smoothstep(0.012,0.035,projectedRadius); -+ fade=float4(particle.appearance.y*smoothstep(0,0.07,age)*(1-smoothstep(0.55,1,life))*nearFade*sizeFade*energy,distance,kind,particle.appearance.z); - } -diff --git a/src/shaders/WaterSprayPS.hlsl b/src/shaders/WaterSprayPS.hlsl ---- a/src/shaders/WaterSprayPS.hlsl -+++ b/src/shaders/WaterSprayPS.hlsl -@@ -8,10 +8,14 @@ - Texture2D waterDepth : register(t4); - #endif - float4 PSMain(float4 position : SV_POSITION, float2 uv : TEXCOORD0, -- float3 world : TEXCOORD1, float2 fade : TEXCOORD2) : SV_TARGET0 { -+ float3 world : TEXCOORD1, float4 fade : TEXCOORD2) : SV_TARGET0 { - WaterDrawParams p=waterDraws[gConstants.drawIndex]; - float radius=dot(uv,uv); -- float alpha=(1-smoothstep(0.10,1,radius))*fade.x; -+ bool mist=fade.z>0.5 && fade.z<1.5; -+ // Streaks are brightest at the head (uv.y=+1) and fade toward the tail. -+ float streak=(1-smoothstep(0.15,1,abs(uv.x)))*smoothstep(-1,0.3,uv.y)*(1-smoothstep(0.75,1,uv.y)); -+ float alpha=(mist ? exp(-radius*2.2)*(1-smoothstep(0.6,1,radius)) : fade.z>1.5 ? streak : 1-smoothstep(0.10,1,radius))*fade.x; -+ float soft=mist ? 40 : 12; - int2 pixel=int2(clamp(position.xy,p.viewBounds.xy,p.viewBounds.zw-1)); - float water=0; - #ifdef SPRAY_MSAA -@@ -23,13 +27,21 @@ - #else - water=waterDepth.Load(int3(pixel,0)); - #endif -- if (water>0) alpha*=saturate((water-fade.y)/12); -+ if (water>0) alpha*=saturate((water-fade.y)/soft); - float depth=sceneDepth.Load(int3(pixel,0)); - if (depth<0.9999) { - float3 background=waterWorldFromPixel(position.xy,depth,p); -- alpha*=saturate((length(background-p.camera.xyz)-fade.y)/12); -+ alpha*=saturate((length(background-p.camera.xyz)-fade.y)/soft); - } - float3 light=(p.material.skyColor.rgb*0.3+p.material.sunColor.rgb*0.7)*p.material.sunColor.a; -+ if (fade.z>0.5) { -+ // Low-pass spray is lit white water, glowing faintly against the sun. -+ light=max(light,float3(0.62,0.66,0.68)*p.material.sunColor.a*2); -+ float scatter=pow(saturate(dot(normalize(world-p.camera.xyz),normalize(p.material.sunDirection.xyz))),6); -+ light+=p.material.sunColor.rgb*scatter*(mist ? 0.55 : 0.35); -+ // A few droplets catch the sun as glints. -+ if (fade.z>1.5 && fade.w>0.85) light+=p.material.sunColor.rgb*(0.25+scatter); -+ } - // Match the course haze without introducing a second exposure system. - float fog=p.fogColor.a*saturate((fade.y-1200)/5000); - light=lerp(light,p.fogColor.rgb,fog); -diff --git a/src/shaders/WaterPS.hlsl b/src/shaders/WaterPS.hlsl ---- a/src/shaders/WaterPS.hlsl -+++ b/src/shaders/WaterPS.hlsl -@@ -301,6 +301,38 @@ - float determinant=dx.x*dy.y-dx.y*dy.x; - float2 wakeGradient=float2(ddx(wake.x)*dy.y-ddy(wake.x)*dx.y,ddy(wake.x)*dx.x-ddx(wake.x)*dy.x)/max(abs(determinant),0.001)*sign(determinant); - ripples += clamp(wakeGradient*8,-0.10,0.10); -+ // Low pass: downwash and hovercraft skirts roughen and whiten a teardrop -+ // of liquid beneath and behind each craft. Analytic, so it never lags. -+ float churn=0,churnFoam=0; -+ float2 churnHeading=0; -+ { -+ float interval=max(m.animation.y-m.animation.x,0.00001); -+ float f=saturate((p.camera.w-m.animation.x)/interval); -+ [loop] for (uint i=0;i0 ? radius*0.7 : radius*3.5; -+ float footprint=exp(-pow(lateral/radius,2)-pow(ahead/extent,2))*wash; -+ if (footprint>churn) { churn=footprint; churnHeading=heading; } -+ } -+ } -+ if (churn>0.001) { -+ // World-anchored streaks along the heading: the craft sweeps over them. -+ float2 across=float2(-churnHeading.y,churnHeading.x); -+ float2 streakAt=float2(dot(worldPosition.xz,across)*0.16,dot(worldPosition.xz,churnHeading)*0.012); -+ float s0=foamNoise(streakAt); -+ float2 slope=across*(foamNoise(streakAt+float2(0.35,0))-s0)*0.5+churnHeading*(foamNoise(streakAt+float2(0,0.03))-s0)*0.2; -+ ripples += clamp(slope*churn*0.6,-0.12,0.12); -+ churnFoam=churn*(0.25+0.75*smoothstep(0.35,0.8,s0))*0.35; -+ } - float3 N = normalize(geometric + float3(-ripples.x, 0, -ripples.y)); - float3 V = normalize(p.camera.xyz - worldPosition); - float3 L = normalize(m.sunDirection.xyz); -@@ -310,7 +342,7 @@ - float nh = saturate(dot(N,H)); - float vh = saturate(dot(V,H)); - float fresnel = 0.0204 + 0.9796 * pow(1-nv, 5); -- float roughness = clamp(m.shallowColor.a + length(float2(ddx(N.x), ddy(N.z))) * 0.2, 0.065, 0.65); -+ float roughness = clamp(m.shallowColor.a + length(float2(ddx(N.x), ddy(N.z))) * 0.2 + churn * 0.25, 0.065, 0.65); - float a2 = pow(roughness, 4); - float d = a2 / max(PI * pow(nh*nh*(a2-1)+1, 2), 0.00001); - float k = pow(roughness + 1, 2) * 0.125; -@@ -405,7 +437,15 @@ - float breakup=foamBreakup(worldPosition.xz,p.camera.w,m.surface.yz); - float shore=wake.z*(1-smoothstep(30,100,abs(worldPosition.y-m.surface.x))); - float foam=saturate((wake.y*1.8+shore*1.3+crest+contact)*m.animation.w*breakup); -+ foam=saturate(foam+churnFoam); - color=lerp(color,float3(0.73,0.79,0.80)*(0.65+0.35*nl)*m.sunColor.a,foam); -+ // Sun glitter on churned liquid; sub-pixel cells are filtered out. -+ float2 glitterAt=worldPosition.xz*0.45; -+ float glitterFilter=1-smoothstep(0.35,1.0,length(fwidth(glitterAt))); -+ if (churn>0.001) { -+ float spark=smoothstep(0.93,1.0,foamHash(floor(glitterAt)+floor(p.camera.w*12)*float2(7.13,3.71))); -+ color+=m.sunColor.rgb*m.sunDirection.a*spark*pow(saturate(dot(reflect(-V,N),L)),4)*churn*glitterFilter*1.2; -+ } - if (p.fogColor.a > 0.5) color = lerp(color, p.fogColor.rgb, saturate(sourceColor.a)); - // The original mirrored scene has already passed through course fog. - // Compose it here to avoid applying that fog a second time and erasing diff --git a/runtime-recomp/cmake/EnhancedWater.cmake b/runtime-recomp/cmake/EnhancedWater.cmake index 4f2d5a51..ed6bb70e 100644 --- a/runtime-recomp/cmake/EnhancedWater.cmake +++ b/runtime-recomp/cmake/EnhancedWater.cmake @@ -41,8 +41,6 @@ set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${water_lava}") set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${water_waterfall}") set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${water_stability}") set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${water_performance}" "${water_reflection_search}") -set(water_lowpass "${DKRPORT_ROOT}/patches/enhanced-water/dkr-lowpass.patch") -set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${water_lowpass}") set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${water_patch}" "${water_adapter}" "${water_budget}" "${water_safety}" "${water_views}" "${water_field_portability}" "${water_rays}" "${water_ray_caps}" "${water_projection}" "${water_recovery}") # Compose the capability adapter on the ACTIVE backend sources, including @@ -153,7 +151,7 @@ foreach(file IN ITEMS WaterProjection.hlsli WaterProjectionProbeCS.hlsl) configure_file("${DKRPORT_ROOT}/patches/enhanced-water/native/${file}" "${water_scratch}/src/shaders/${file}" COPYONLY) endforeach() execute_process(COMMAND "${GIT_EXECUTABLE}" init --quiet "${water_scratch}" COMMAND_ERROR_IS_FATAL ANY) -foreach(patch IN ITEMS "${water_patch}" "${water_adapter}" "${water_budget}" "${water_safety}" "${water_views}" "${water_field_portability}" "${water_rays}" "${water_ray_caps}" "${water_projection}" "${water_recovery}" "${water_performance}" "${water_reflection_search}" "${water_stability}" "${water_waterfall}" "${water_lava}" "${water_lava_lighting}" "${water_lava_glow}" "${water_lowpass}") +foreach(patch IN ITEMS "${water_patch}" "${water_adapter}" "${water_budget}" "${water_safety}" "${water_views}" "${water_field_portability}" "${water_rays}" "${water_ray_caps}" "${water_projection}" "${water_recovery}" "${water_performance}" "${water_reflection_search}" "${water_stability}" "${water_waterfall}" "${water_lava}" "${water_lava_lighting}" "${water_lava_glow}") foreach(mode IN ITEMS check apply) set(flags --recount --ignore-space-change --whitespace=error) if(mode STREQUAL "check") diff --git a/scripts/compose-water-lowpass.py b/scripts/compose-water-lowpass.py deleted file mode 100644 index 89ca1d77..00000000 --- a/scripts/compose-water-lowpass.py +++ /dev/null @@ -1,323 +0,0 @@ -"""Author the low-pass spray layer: aircraft/hovercraft skimming over liquid. - -Read the composed stage; emit one pipeline patch. Re-runs reverse this layer -in memory first, so the patch is always authored against the lava-glow -baseline (which also carries the caustic texture). -""" -from pathlib import Path -import difflib -import sys -from water_patch_baseline import read_before - -root=Path(__file__).resolve().parents[1] -stage=Path(sys.argv[1]).resolve() -output=root/'patches/enhanced-water/dkr-lowpass.patch' -layers=[] -if 'WATER_CRAFT_AIR' in (stage/'src/shared/rt64_water_params.h').read_text():layers.append(output) -changes=[] - -def replace(t,a,b): - if t.count(a)!=1:raise SystemExit(f'Non-unique low-pass context: {a[:120]}') - return t.replace(a,b,1) - -def change(path,fn): - before=read_before(stage,path,layers);after=fn(before) - if before==after:raise SystemExit(f'No low-pass change: {path}') - changes.append(f'diff --git a/{path} b/{path}\n') - changes.extend(difflib.unified_diff(before.splitlines(True),after.splitlines(True), - fromfile=f'a/{path}',tofile=f'b/{path}',n=3)) - -def params(t): - t=replace(t,'#define WATER_SHORE_SEGMENTS_PER_TILE 8\n','''#define WATER_SHORE_SEGMENTS_PER_TILE 8 -// Low passes: clearance above the liquid at which a skimming craft still -// disturbs it, full-strength clearance, and the speed for full spray. -#define WATER_LOWPASS_PLANE_CEILING 300.0f -#define WATER_LOWPASS_PLANE_FULL 30.0f -#define WATER_LOWPASS_HOVER_CEILING 120.0f -#define WATER_LOWPASS_HOVER_FULL 20.0f -#define WATER_LOWPASS_HOVER_SCALE 0.55f -#define WATER_LOWPASS_FULL_SPEED 900.0f -''') - t=replace(t,''' float4 shoreSegments[WATER_CRAFT_COUNT * WATER_SHORE_SEGMENTS_PER_TILE]; // immutable mean-water intersections, XZ endpoints -''',''' // Water draws carry low-pass clearance in the last two shoreline vectors (WATER_CRAFT_AIR). - float4 shoreSegments[WATER_CRAFT_COUNT * WATER_SHORE_SEGMENTS_PER_TILE]; // immutable mean-water intersections, XZ endpoints -''') - t=replace(t,'''struct WaterDrawParams { -''','''// Low-pass clearance above the liquid, four crafts per vector: positive -// values are aircraft, negative values hovercraft, zero means no low pass. -// WaterDrawParams is exactly at the 2048-byte structured-buffer stride, so -// the clearance shares the last two shoreline vectors instead of growing it. -// Water draws publish no shoreline segments (every shoreCounts entry stays -// zero, so the field shaders never read them) and lava draws reuse only -// shoreSegments[0..15] for emitters and write no clearance. Low passes are -// gated on active crafts (W>=0), which lava and vertical water never have. -#define WATER_CRAFT_AIR_SEGMENT (WATER_CRAFT_COUNT * WATER_SHORE_SEGMENTS_PER_TILE - WATER_CRAFT_COUNT / 4) -#define WATER_CRAFT_AIR(m, i) ((m).shoreSegments[WATER_CRAFT_AIR_SEGMENT + (i) / 4][(i) % 4]) - -struct WaterDrawParams { -''') - t=replace(t,'''static_assert(sizeof(WaterDrawParams) <= 2048, "D3D structured-buffer stride limit"); -''','''static_assert(sizeof(WaterDrawParams) <= 2048, "D3D structured-buffer stride limit"); -static_assert(WATER_CRAFT_AIR_SEGMENT >= 16, "Low-pass clearance must not overlap the lava emitters"); -''') - return t+''' -#ifndef HLSL_CPU -// Matches lowPassStrength in rt64_water_renderer.cpp. -float waterLowPassStrength(float air) { - if (air>0) return 1-smoothstep(WATER_LOWPASS_PLANE_FULL,WATER_LOWPASS_PLANE_CEILING,air); - if (air<0) return WATER_LOWPASS_HOVER_SCALE*(1-smoothstep(WATER_LOWPASS_HOVER_FULL,WATER_LOWPASS_HOVER_CEILING,-air)); - return 0; -} -#endif -''' -change('src/shared/rt64_water_params.h',params) - -def header(t): - return replace(t,' std::array sprayHeading{};\n', - ' std::array sprayHeading{};\n' - ' std::array lowPassDistance{};\n') -change('src/render/rt64_water_renderer.h',header) - -def renderer(t): - t=replace(t,'void WaterRenderer::stepSpray(const interop::WaterMaterial &m,float fromTime,float toTime,bool reset) {\n', - '''// Matches waterLowPassStrength in rt64_water_params.h. -static float lowPassStrength(float air) { - const auto fade=[](float full,float ceiling,float clearance) { - const float t=std::clamp((clearance-full)/(ceiling-full),0.0f,1.0f); - return 1-t*t*(3-2*t); - }; - if (air>0) return fade(WATER_LOWPASS_PLANE_FULL,WATER_LOWPASS_PLANE_CEILING,air); - if (air<0) return WATER_LOWPASS_HOVER_SCALE*fade(WATER_LOWPASS_HOVER_FULL,WATER_LOWPASS_HOVER_CEILING,-air); - return 0; -} - -void WaterRenderer::stepSpray(const interop::WaterMaterial &m,float fromTime,float toTime,bool reset) { -''') - t=replace(t,' particles.clear(); sprayDistance.fill(0); sprayHeading.fill({0,0,0,0});\n', - ' particles.clear(); sprayDistance.fill(0); sprayHeading.fill({0,0,0,0}); lowPassDistance.fill(0);\n') - a=t.index('void WaterRenderer::drawSpray') - end=t.rindex(' }\n}\n',0,a) - t=t[:end]+''' } - // Low passes lift spray from the liquid beneath the craft, not from its - // hull: outward curtains (kind 2) and slowly settling mist (kind 1). - for (uint32_t i=0;i0;});''', - ''' // A low pass over the liquid keeps the field alive without hull contact. - bool lowPass=false; - for (uint32_t i=0;i=0 && WATER_CRAFT_AIR(m,i)!=0; - const bool active=horizontal!=draws.end() && (lowPass || - std::any_of(std::begin(m.craftCurrent),std::end(m.craftCurrent),[](const auto& c){return c.w>0;}));''') - return t -change('src/render/rt64_water_renderer.cpp',renderer) - -def field(t): - t=replace(t,'groupshared float4 craftResponse[WATER_CRAFT_COUNT];\n', - 'groupshared float4 craftResponse[WATER_CRAFT_COUNT];\n' - 'groupshared float4 craftWash[WATER_CRAFT_COUNT];\n') - t=replace(t,''' craftResponse[lane]=float4(saturate((old.y-current.y)/interval/180),landed ? 1 : 0,current.w,0); -''',''' craftResponse[lane]=float4(saturate((old.y-current.y)/interval/180),landed ? 1 : 0,current.w,0); - // Low pass: a footprint trailing beneath the craft, wider when higher. - float air=WATER_CRAFT_AIR(m,lane); - float clearance=abs(air); - float wash=current.w>=0 ? waterLowPassStrength(air)*saturate(distance/interval/WATER_LOWPASS_FULL_SPEED) : 0; - craftWash[lane]=float4(to-heading*(10+clearance*0.15),20+clearance*0.3,wash*min(length(to-from)/cell,2)); -''') - t=replace(t,''' foam += ring*0.65; - velocity += ring*0.7; - } -''',''' foam += ring*0.65; - velocity += ring*0.7; - } - float4 wash=craftWash[i]; - if (wash.w>0) { - float2 offset=world-wash.xy; - float r=wash.z, behind=max(-dot(offset,heading),0); - float lateral=abs(dot(offset,float2(-heading.y,heading.x))); - // The moving pressure dent radiates its own waves; curtains spread - // wider than the hull wake; a churned trail follows the path. - float dent=exp(-dot(offset,offset)/(r*r)); - float curtain=exp(-pow((lateral-behind*0.55-r*0.35)/(6+r*0.25),2))*smoothstep(0,r*0.5,behind)*(1-smoothstep(r*2.5,r*4.5,behind)); - float churn=exp(-pow(d/(r*0.5),2)); - foam += (churn*0.035+curtain*0.03)*wash.w; - velocity += (curtain*0.05-dent*0.12)*wash.w; - } -''') - return t -change('src/shaders/WaterFieldCS.hlsl',field) - -def spray_vs(t): - t=replace(t,' out float3 world : TEXCOORD1, out float2 fade : TEXCOORD2) {', - ' out float3 world : TEXCOORD1, out float4 fade : TEXCOORD2) {') - t=replace(t,''' float drag=1.5, response=(1-exp(-drag*age))/drag; - world=particle.origin.xyz+particle.velocity.xyz*response; - world.y-=140*(age-response)/drag; - float4 clip=mul(p.viewProjection,float4(world,1)); - float4 top=mul(p.viewProjection,float4(world+float3(0,particle.appearance.x,0),1));''',''' // Kind 0 is hull spray, 1 low-pass mist, 2 low-pass curtain droplets. - float kind=particle.appearance.w; - bool mist=kind>0.5 && kind<1.5, streak=kind>1.5; - float life=saturate(age/particle.velocity.w); - float drag=mist ? 2.6 : 1.5, gravity=mist ? 12 : 140, response=(1-exp(-drag*age))/drag; - world=particle.origin.xyz+particle.velocity.xyz*response; - world.y-=gravity*(age-response)/drag; - float size=particle.appearance.x*(mist ? 1+life*1.2 : 1); - float4 clip=mul(p.viewProjection,float4(world,1)); - float4 top=mul(p.viewProjection,float4(world+float3(0,size,0),1));''') - t=replace(t,''' position=float4(xy*clip.w,screen.z*clip.w,clip.w); - float radius=clamp(projectedRadius,0.0005,0.035); - position.xy+=uv*radius*float2(p.screen.y/p.screen.x,1)*clip.w; - float life=saturate(age/particle.velocity.w); - float distance=length(world-p.camera.xyz); - float nearFade=smoothstep(particle.appearance.x*4,particle.appearance.x*12,distance); - float sizeFade=1-smoothstep(0.012,0.035,projectedRadius); - fade=float2(particle.appearance.y*smoothstep(0,0.07,age)*(1-smoothstep(0.55,1,life))*nearFade*sizeFade,distance);''',''' position=float4(xy*clip.w,screen.z*clip.w,clip.w); - // Streaks keep a minimum width so fine droplets stay visible. - float radius=clamp(projectedRadius,streak ? 0.0022 : 0.0005,mist ? 0.24 : streak ? 0.12 : 0.035); - float2 aspect=float2(p.screen.y/p.screen.x,1); - float2 offset=uv*radius*aspect; - float energy=1; - if (streak) { - // Stretch curtain droplets along their recent screen motion. - float decay=exp(-drag*age); - float3 velocity=particle.velocity.xyz*decay-float3(0,gravity*(1-decay)/drag,0); - float4 tail=mul(p.viewProjection,float4(world-velocity*0.09,1)); - if (all(isfinite(tail)) && tail.w>0.01) { - float3 tailScreen=tail.xyz/tail.w*float3(1,-1,1)*p.viewportScale.xyz+p.viewportTranslate.xyz; - float2 tailXY=(tailScreen.xy/p.screen.xy*float2(2,-2)+float2(-1,1))*p.screenTransform.xy+p.screenTransform.zw; - float2 trail=(xy-tailXY)/aspect; - float span=length(trail); - if (all(isfinite(trail)) && span>radius*0.5) { - float2 along=trail/span, across=float2(-along.y,along.x); - span=min(span,0.2); - offset=(across*uv.x*radius+along*(uv.y*(span*0.5+radius)-span*0.5))*aspect; - energy=pow(saturate(2*radius/(span+2*radius)),0.3); - } - } - } - position.xy+=offset*clip.w; - float distance=length(world-p.camera.xyz); - float nearFade=mist ? smoothstep(size*1.5,size*4,distance) : smoothstep(particle.appearance.x*4,particle.appearance.x*12,distance); - float sizeFade=mist ? 1-smoothstep(0.14,0.24,projectedRadius) : streak ? 1-smoothstep(0.07,0.12,projectedRadius) : 1-smoothstep(0.012,0.035,projectedRadius); - fade=float4(particle.appearance.y*smoothstep(0,0.07,age)*(1-smoothstep(0.55,1,life))*nearFade*sizeFade*energy,distance,kind,particle.appearance.z);''') - return t -change('src/shaders/WaterSprayVS.hlsl',spray_vs) - -def spray_ps(t): - t=replace(t,' float3 world : TEXCOORD1, float2 fade : TEXCOORD2) : SV_TARGET0 {', - ' float3 world : TEXCOORD1, float4 fade : TEXCOORD2) : SV_TARGET0 {') - t=replace(t,''' float alpha=(1-smoothstep(0.10,1,radius))*fade.x;''',''' bool mist=fade.z>0.5 && fade.z<1.5; - // Streaks are brightest at the head (uv.y=+1) and fade toward the tail. - float streak=(1-smoothstep(0.15,1,abs(uv.x)))*smoothstep(-1,0.3,uv.y)*(1-smoothstep(0.75,1,uv.y)); - float alpha=(mist ? exp(-radius*2.2)*(1-smoothstep(0.6,1,radius)) : fade.z>1.5 ? streak : 1-smoothstep(0.10,1,radius))*fade.x; - float soft=mist ? 40 : 12;''') - t=replace(t,' if (water>0) alpha*=saturate((water-fade.y)/12);', - ' if (water>0) alpha*=saturate((water-fade.y)/soft);') - t=replace(t,' alpha*=saturate((length(background-p.camera.xyz)-fade.y)/12);', - ' alpha*=saturate((length(background-p.camera.xyz)-fade.y)/soft);') - t=replace(t,''' float3 light=(p.material.skyColor.rgb*0.3+p.material.sunColor.rgb*0.7)*p.material.sunColor.a; -''',''' float3 light=(p.material.skyColor.rgb*0.3+p.material.sunColor.rgb*0.7)*p.material.sunColor.a; - if (fade.z>0.5) { - // Low-pass spray is lit white water, glowing faintly against the sun. - light=max(light,float3(0.62,0.66,0.68)*p.material.sunColor.a*2); - float scatter=pow(saturate(dot(normalize(world-p.camera.xyz),normalize(p.material.sunDirection.xyz))),6); - light+=p.material.sunColor.rgb*scatter*(mist ? 0.55 : 0.35); - // A few droplets catch the sun as glints. - if (fade.z>1.5 && fade.w>0.85) light+=p.material.sunColor.rgb*(0.25+scatter); - } -''') - return t -change('src/shaders/WaterSprayPS.hlsl',spray_ps) - -def surface(t): - t=replace(t,''' ripples += clamp(wakeGradient*8,-0.10,0.10); -''',''' ripples += clamp(wakeGradient*8,-0.10,0.10); - // Low pass: downwash and hovercraft skirts roughen and whiten a teardrop - // of liquid beneath and behind each craft. Analytic, so it never lags. - float churn=0,churnFoam=0; - float2 churnHeading=0; - { - float interval=max(m.animation.y-m.animation.x,0.00001); - float f=saturate((p.camera.w-m.animation.x)/interval); - [loop] for (uint i=0;i0 ? radius*0.7 : radius*3.5; - float footprint=exp(-pow(lateral/radius,2)-pow(ahead/extent,2))*wash; - if (footprint>churn) { churn=footprint; churnHeading=heading; } - } - } - if (churn>0.001) { - // World-anchored streaks along the heading: the craft sweeps over them. - float2 across=float2(-churnHeading.y,churnHeading.x); - float2 streakAt=float2(dot(worldPosition.xz,across)*0.16,dot(worldPosition.xz,churnHeading)*0.012); - float s0=foamNoise(streakAt); - float2 slope=across*(foamNoise(streakAt+float2(0.35,0))-s0)*0.5+churnHeading*(foamNoise(streakAt+float2(0,0.03))-s0)*0.2; - ripples += clamp(slope*churn*0.6,-0.12,0.12); - churnFoam=churn*(0.25+0.75*smoothstep(0.35,0.8,s0))*0.35; - } -''') - t=replace(t,''' float roughness = clamp(m.shallowColor.a + length(float2(ddx(N.x), ddy(N.z))) * 0.2, 0.065, 0.65);''', - ''' float roughness = clamp(m.shallowColor.a + length(float2(ddx(N.x), ddy(N.z))) * 0.2 + churn * 0.25, 0.065, 0.65);''') - t=replace(t,''' float foam=saturate((wake.y*1.8+shore*1.3+crest+contact)*m.animation.w*breakup); - color=lerp(color,float3(0.73,0.79,0.80)*(0.65+0.35*nl)*m.sunColor.a,foam); -''',''' float foam=saturate((wake.y*1.8+shore*1.3+crest+contact)*m.animation.w*breakup); - foam=saturate(foam+churnFoam); - color=lerp(color,float3(0.73,0.79,0.80)*(0.65+0.35*nl)*m.sunColor.a,foam); - // Sun glitter on churned liquid; sub-pixel cells are filtered out. - float2 glitterAt=worldPosition.xz*0.45; - float glitterFilter=1-smoothstep(0.35,1.0,length(fwidth(glitterAt))); - if (churn>0.001) { - float spark=smoothstep(0.93,1.0,foamHash(floor(glitterAt)+floor(p.camera.w*12)*float2(7.13,3.71))); - color+=m.sunColor.rgb*m.sunDirection.a*spark*pow(saturate(dot(reflect(-V,N),L)),4)*churn*glitterFilter*1.2; - } -''') - return t -change('src/shaders/WaterPS.hlsl',surface) - -output.write_text(''.join(changes),newline='\n') -print('Generated low-pass spray patch.') From 275a8c61ce1cc9901130ce1a55980e9ee7d20862 Mon Sep 17 00:00:00 2001 From: leite-matheus <78598648+leite-matheus@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:58:08 -0300 Subject: [PATCH 11/11] Check drifted v1.0.7 capsule bases against their pinned Git blob Twelve first-party files evolved by the Track Lab, modern camera and enhanced-water work are bases of the v1.0.7 source capsule. Like the existing drifted bases, their pins are now checked against the historical blob restore_capsule.py reads, which still matches. --- scripts/check_pinned_bytes.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scripts/check_pinned_bytes.py b/scripts/check_pinned_bytes.py index 1114937c..b0354179 100755 --- a/scripts/check_pinned_bytes.py +++ b/scripts/check_pinned_bytes.py @@ -62,6 +62,19 @@ "runtime-recomp/src/game/runtime_ui.cpp", "runtime-recomp/src/game/updates/update_process.cpp", "runtime-recomp/src/game/updates/update_process.hpp", + # Evolved by the Track Lab, modern camera and enhanced-water work. + "runtime-recomp/src/game/custom_tracks.hpp", + "runtime-recomp/src/game/custom_tracks_hooks.cpp", + "runtime-recomp/src/game/enhanced_water_rt64.hpp", + "runtime-recomp/src/game/modern_cam/modern_cam.c", + "runtime-recomp/src/game/modern_cam/modern_cam.h", + "runtime-recomp/src/game/modern_camera.cpp", + "runtime-recomp/src/game/modern_camera.hpp", + "runtime-recomp/src/game/modern_camera_dkr.hpp", + "runtime-recomp/src/game/mods/legacy_character_presentation.hpp", + "runtime-recomp/src/game/presentation_identity.cpp", + "runtime-recomp/src/game/revision_addresses.hpp", + "runtime-recomp/src/game/runtime_camera_tuning_ui.inl", } ) CAPSULE = re.compile(r"^patches/release-prep/[^/]+/source-capsule-[^/]+/(?P[0-9a-f]{64})\.[A-Za-z0-9]+$")