diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ef89a4..0fd0a6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,19 @@ Steam Deck Wi-Fi and separate-machine acceptance remain necessary. See the release validation record for exact final-build coverage. +- 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/patches/enhanced-water/dkr-lava-glow.patch b/patches/enhanced-water/dkr-lava-glow.patch index 3b07bdbf..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(); + 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(); + }; +@@ -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); + + 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 +@@ -5,6 +5,7 @@ + #include "rt64_rsp_processor.h" + + #include "rt64_buffer_uploader.h" ++#include "lava_lighting_policy.hpp" + + namespace RT64 { + // 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)); ++ processCB.lavaGlowCooling=dkr::runtime::water::enhanced::lava_cooling(glowTime,drawData.lavaGlowStrength,drawData.lavaGlowSpeed); ++ processCB.lavaGlowScale=drawData.lavaGlowActive ? std::clamp(drawData.lavaGlowIntensity,0.0f,3.0f) * ++ 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; + 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; + 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 -@@ -17,5 +17,8 @@ +@@ -16,7 +16,10 @@ namespace RT64 { + float prevFrameWeight; float curFrameWeight; uint32_t waterWorldActive; + float lavaGlowScale,lavaGlowCooling; @@ -78,10 +147,12 @@ diff --git a/src/render/rt64_rsp_processor.h b/src/render/rt64_rsp_processor.h + static_assert(sizeof(ProcessCB)==32,"RSP HLSL constant rows"); 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 -@@ -543,5 +543,6 @@ +@@ -542,7 +542,8 @@ namespace RT64 { + { RSPProcessDescriptorSet descriptorSet; layoutBuilder.begin(); - layoutBuilder.addPushConstant(0, 0, sizeof(uint32_t) * 4, RenderShaderStageFlag::COMPUTE); @@ -89,34 +160,308 @@ diff --git a/src/render/rt64_shader_library.cpp b/src/render/rt64_shader_library + layoutBuilder.addPushConstant(0, 0, sizeof(uint32_t) * 8, RenderShaderStageFlag::COMPUTE); layoutBuilder.addDescriptorSet(descriptorSet); layoutBuilder.end(); -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 @@ + 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 +@@ -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 @@ 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); ++ // 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 @@ 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(); + } - #include "rt64_buffer_uploader.h" -+#include "lava_lighting_policy.hpp" + 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))); + 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; + } catch(...) { +@@ -498,9 +549,20 @@ void WaterRenderer::beginColor(RenderWorker *worker,uint32_t drawIndex) { + worker->commandList->setFramebuffer(scene.waterColorFramebuffer.get()); + } - namespace RT64 { -@@ -25,4 +26,8 @@ - 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)); -+ processCB.lavaGlowCooling=dkr::runtime::water::enhanced::lava_cooling(glowTime,drawData.lavaGlowStrength,drawData.lavaGlowSpeed); -+ processCB.lavaGlowScale=drawData.lavaGlowActive ? std::clamp(drawData.lavaGlowIntensity,0.0f,3.0f) * -+ 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 @@ - processSet->setBuffer(processSet->dstWorld, p.outputBuffers->rasterWorldPosBuffer.buffer.get(), RenderBufferStructuredView(sizeof(float) * 4)); ++// 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;isetBuffer(processSet->srcLavaGlow,p.drawBuffers->lavaGlowBuffer.get(),p.drawBuffers->lavaGlowBuffer.getView(0)); - const uint32_t modifyCount = drawData.modifyCount(); - modifyCB.modifyCount = modifyCount; + void WaterRenderer::drawSpray(RenderWorker *worker,uint32_t drawIndex) { +@@ -576,7 +677,11 @@ void WaterRenderer::prepareField(RenderWorker *worker) { + 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; +- const bool active=horizontal!=draws.end() && std::any_of(std::begin(m.craftCurrent),std::end(m.craftCurrent),[](const auto& c){return c.w>0;}); ++ // 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) { ++ 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; + 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] = {}; ++ double milliseconds[5] = {}; + for (uint32_t i=0;i= times[i*2]) milliseconds[profileKinds[i]] += double(times[i*2+1]-times[i*2])/1000000.0; + } +- for(unsigned i=0;i<4;i++)profileTotals[i]+=milliseconds[i]; +- profileTotals[4]+=frameMilliseconds; ++ for(unsigned i=0;i<5;i++)profileTotals[i]+=milliseconds[i]; ++ profileTotals[5]+=frameMilliseconds; + if(++profileFrames==120) { +- std::fprintf(stderr,"[water][timing] frames=%u normal-ms=%.3f capture-ms=%.3f surface-ms=%.3f field-ms=%.3f frame-gpu-ms=%.3f bytes=%llu\n", ++ std::fprintf(stderr,"[water][timing] frames=%u normal-ms=%.3f capture-ms=%.3f surface-ms=%.3f field-ms=%.3f lava-ms=%.3f frame-gpu-ms=%.3f bytes=%llu\n", + profileFrames,profileTotals[0]/profileFrames,profileTotals[1]/profileFrames,profileTotals[2]/profileFrames, +- profileTotals[3]/profileFrames,profileTotals[4]/profileFrames,static_cast(allocationBytes())); ++ profileTotals[3]/profileFrames,profileTotals[4]/profileFrames,profileTotals[5]/profileFrames,static_cast(allocationBytes())); + profileFrames=0;profileTotals.fill(0); + } + 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"); ++ if (file) std::fprintf(file,"course,quality,debug,views,width,height,water_draws,normal_vertices,normal_ms,capture_ms,surface_ms,field_ms,lava_ms,frame_gpu_ms\n"); + return file; + }(); + if (csv) { + const auto &d = draws.front(); +- std::fprintf(csv,"%.0f,%.0f,%.0f,%zu,%.0f,%.0f,%zu,%zu,%.5f,%.5f,%.5f,%.5f,%.5f\n",d.material.identity.z,d.material.identity.x,d.material.identity.y,sceneKeys.size(),d.screen.z,d.screen.w, +- drawScenes.size(),normalVertices.size(),milliseconds[0],milliseconds[1],milliseconds[2],milliseconds[3],frameMilliseconds); ++ std::fprintf(csv,"%.0f,%.0f,%.0f,%zu,%.0f,%.0f,%zu,%zu,%.5f,%.5f,%.5f,%.5f,%.5f,%.5f\n",d.material.identity.z,d.material.identity.x,d.material.identity.y,sceneKeys.size(),d.screen.z,d.screen.w, ++ drawScenes.size(),normalVertices.size(),milliseconds[0],milliseconds[1],milliseconds[2],milliseconds[3],milliseconds[4],frameMilliseconds); + std::fflush(csv); + } + } +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; + + 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 -@@ -18,4 +18,7 @@ +@@ -17,6 +17,9 @@ struct RSPProcessCB { + float prevFrameWeight; float curFrameWeight; uint waterWorldActive; + float lavaGlowScale; @@ -124,13 +469,17 @@ diff --git a/src/shaders/RSPProcessCS.hlsl b/src/shaders/RSPProcessCS.hlsl + uint reserved; }; -@@ -43,4 +46,5 @@ + [[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)] -@@ -133,4 +137,9 @@ + 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) { @@ -140,10 +489,65 @@ diff --git a/src/shaders/RSPProcessCS.hlsl b/src/shaders/RSPProcessCS.hlsl + } 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 -@@ -250,4 +250,8 @@ +@@ -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 @@ -152,72 +556,302 @@ 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 @@ + 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 { -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 -@@ -239,5 +239,5 @@ - std::vector profileKinds; - uint32_t profileFrames=0; -- std::array profileTotals{}; -+ std::array profileTotals{}; - bool usesHDR; - -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 @@ - profilePool->queryResults(); - const auto *times = profilePool->getResults(); -- double milliseconds[4] = {}; -+ double milliseconds[5] = {}; - for (uint32_t i=0;i= times[i*2]) milliseconds[profileKinds[i]] += double(times[i*2+1]-times[i*2])/1000000.0; - } -- for(unsigned i=0;i<4;i++)profileTotals[i]+=milliseconds[i]; -- profileTotals[4]+=frameMilliseconds; -+ for(unsigned i=0;i<5;i++)profileTotals[i]+=milliseconds[i]; -+ profileTotals[5]+=frameMilliseconds; - if(++profileFrames==120) { -- std::fprintf(stderr,"[water][timing] frames=%u normal-ms=%.3f capture-ms=%.3f surface-ms=%.3f field-ms=%.3f frame-gpu-ms=%.3f bytes=%llu\n", -+ std::fprintf(stderr,"[water][timing] frames=%u normal-ms=%.3f capture-ms=%.3f surface-ms=%.3f field-ms=%.3f lava-ms=%.3f frame-gpu-ms=%.3f bytes=%llu\n", - profileFrames,profileTotals[0]/profileFrames,profileTotals[1]/profileFrames,profileTotals[2]/profileFrames, -- profileTotals[3]/profileFrames,profileTotals[4]/profileFrames,static_cast(allocationBytes())); -+ profileTotals[3]/profileFrames,profileTotals[4]/profileFrames,profileTotals[5]/profileFrames,static_cast(allocationBytes())); - profileFrames=0;profileTotals.fill(0); + 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); } -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 @@ - 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(); + 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/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) diff --git a/runtime-recomp/assets/water/WaterCaustics.png b/runtime-recomp/assets/water/WaterCaustics.png new file mode 100644 index 00000000..a8338448 Binary files /dev/null and b/runtime-recomp/assets/water/WaterCaustics.png differ diff --git a/runtime-recomp/cmake/EnhancedWater.cmake b/runtime-recomp/cmake/EnhancedWater.cmake index 57f2dad9..ed6bb70e 100644 --- a/runtime-recomp/cmake/EnhancedWater.cmake +++ b/runtime-recomp/cmake/EnhancedWater.cmake @@ -280,5 +280,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}/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" + 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/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/enhanced_water_rt64.hpp b/runtime-recomp/src/game/enhanced_water_rt64.hpp index 11353121..ebf9be03 100644 --- a/runtime-recomp/src/game/enhanced_water_rt64.hpp +++ b/runtime-recomp/src/game/enhanced_water_rt64.hpp @@ -56,10 +56,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); @@ -72,7 +74,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); @@ -87,6 +98,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; } @@ -169,6 +192,12 @@ inline void apply_material(RT64::State& state, const Registry& registry, material.craftCurrent[i].w=-1; } } + if(!lava) { + // Low-pass clearance shares the last two shoreline vectors + // (WATER_CRAFT_AIR): water draws publish no shoreline segments, + // and lava reuses only shoreSegments[0..15], so it gets none. + for(unsigned i=0;i<8;++i)WATER_CRAFT_AIR(material,i)=crafts.air[i]; + } if(lava) { // Lava has no shoreline/wake field. Reuse its unused shoreline // vectors without changing the full 2048-byte shader ABI. Keep 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/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/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/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_camera_tuning_ui.inl b/runtime-recomp/src/game/runtime_camera_tuning_ui.inl index eb70786a..66780dc6 100644 --- a/runtime-recomp/src/game/runtime_camera_tuning_ui.inl +++ b/runtime-recomp/src/game/runtime_camera_tuning_ui.inl @@ -28,9 +28,10 @@ constexpr std::array kCameraTuningGroups{{ {camtune::TuningGroup::Roll, "Roll", "A slight tilt as the racer turns.", false}, {camtune::TuningGroup::Orbit, "Manual orbit", "Turn the camera with the right stick or mouse. Hold the right mouse button to keep the view; Escape frees the pointer.", 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 rises over the racer rather than into it.", false}, {camtune::TuningGroup::Shake, "Shake", - "Shake from boosts, landings and impacts. Zero turns shake off.", true}, + "Shake from boosts and landings. Zero turns shake off. Hits and explosions never shake this camera.", true}, {camtune::TuningGroup::Hits, "Hits", "Lower values smooth the camera after collisions. 100% follows the impact immediately.", true}, @@ -55,11 +56,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/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/src/game/runtime_legacy_mods.cpp b/runtime-recomp/src/game/runtime_legacy_mods.cpp index c1eb1dae..1bc5b1d0 100644 --- a/runtime-recomp/src/game/runtime_legacy_mods.cpp +++ b/runtime-recomp/src/game/runtime_legacy_mods.cpp @@ -206,6 +206,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; 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); diff --git a/runtime-recomp/tests/launcher_copy_inventory.json b/runtime-recomp/tests/launcher_copy_inventory.json index 40a3500b..061f00df 100644 --- a/runtime-recomp/tests/launcher_copy_inventory.json +++ b/runtime-recomp/tests/launcher_copy_inventory.json @@ -2217,7 +2217,7 @@ "id": "COPY-317", "file": "runtime_camera_tuning_ui.inl", "previous": "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.", - "approved": "Shake from boosts, landings and impacts. Zero turns shake off.", + "approved": "Shake from boosts and landings. Zero turns shake off. Hits and explosions never shake this camera.", "verification": "literal" }, { 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()) { 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]+$") 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_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/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/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/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/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/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/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 " 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(