yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
track_collision_generator.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <array>
5#include <cstdint>
6#include <sstream>
7#include <string>
8#include <utility>
9
10#include "absl/status/status.h"
11#include "absl/strings/str_format.h"
12#include "core/features.h"
13#include "rom/snes.h"
14#include "rom/write_fence.h"
15#include "util/macro.h"
19
20namespace yaze {
21namespace zelda3 {
22
23namespace {
24
25constexpr int kGridSize = 64;
26constexpr uint16_t kCollisionSingleTileMarker = 0xF0F0;
27constexpr uint16_t kCollisionEndMarker = 0xFFFF;
30
32 int room_id = 0;
33 uint32_t start = 0;
34 uint32_t end = 0;
35};
36
37absl::StatusOr<uint32_t> FindCollisionBlobEnd(const std::vector<uint8_t>& data,
38 uint32_t start, size_t safe_end,
39 int room_id) {
40 size_t cursor = start;
41 bool single_mode = false;
42
43 while (cursor + 1 < safe_end) {
44 const uint16_t value = data[cursor] | (data[cursor + 1] << 8);
45 cursor += 2;
46 if (value == kCollisionEndMarker) {
47 return static_cast<uint32_t>(cursor);
48 }
49 if (value == kCollisionSingleTileMarker) {
50 single_mode = true;
51 continue;
52 }
53
54 if (single_mode) {
55 if (cursor >= safe_end) {
56 break;
57 }
58 ++cursor;
59 continue;
60 }
61
62 if (cursor + 1 >= safe_end) {
63 break;
64 }
65 const size_t width = data[cursor];
66 const size_t height = data[cursor + 1];
67 cursor += 2;
68 const size_t payload_size = width * height;
69 if (payload_size > safe_end - cursor) {
70 break;
71 }
72 cursor += payload_size;
73 }
74
75 return absl::FailedPreconditionError(absl::StrFormat(
76 "Custom collision data for room 0x%02X is unterminated before "
77 "WaterFill reserved region",
78 room_id));
79}
80
81bool BlobsOverlap(const CollisionBlob& lhs, const CollisionBlob& rhs) {
82 return lhs.start < rhs.end && rhs.start < lhs.end;
83}
84
85// Map corner type to its switch equivalent for promotion.
87 switch (corner) {
96 default:
97 return corner;
98 }
99}
100
101bool IsCornerTile(uint8_t tile) {
102 return tile >= 0xB2 && tile <= 0xB5;
103}
104
105// Classify a tile based on its 4-neighbor connectivity.
106//
107// The algorithm: for each occupied tile in the grid, check which of the
108// 4 cardinal neighbors are also occupied. The pattern of neighbors uniquely
109// determines the tile type:
110// - 1 neighbor (endpoint) → stop tile, direction based on which neighbor
111// - 2 neighbors (line or corner) → straight or corner
112// - 3 neighbors → T-junction
113// - 4 neighbors → intersection
114uint8_t ClassifyTile(bool up, bool down, bool left, bool right) {
115 int count = (up ? 1 : 0) + (down ? 1 : 0) + (left ? 1 : 0) + (right ? 1 : 0);
116
117 if (count == 0) {
118 // Isolated tile — treat as intersection (shouldn't happen in practice)
119 return static_cast<uint8_t>(TrackTileType::Intersection);
120 }
121
122 if (count == 1) {
123 // Endpoint → stop tile. Direction is where the neighbor IS, because
124 // the cart arrives from that direction and will depart back that way.
125 if (down)
126 return static_cast<uint8_t>(TrackTileType::StopNorth);
127 if (up)
128 return static_cast<uint8_t>(TrackTileType::StopSouth);
129 if (right)
130 return static_cast<uint8_t>(TrackTileType::StopWest);
131 if (left)
132 return static_cast<uint8_t>(TrackTileType::StopEast);
133 }
134
135 if (count == 2) {
136 // Two neighbors — either a straight line or a corner
137 if (left && right)
138 return static_cast<uint8_t>(TrackTileType::HorizStraight);
139 if (up && down)
140 return static_cast<uint8_t>(TrackTileType::VertStraight);
141 if (down && right)
142 return static_cast<uint8_t>(TrackTileType::CornerTL);
143 if (up && right)
144 return static_cast<uint8_t>(TrackTileType::CornerBL);
145 if (down && left)
146 return static_cast<uint8_t>(TrackTileType::CornerTR);
147 if (up && left)
148 return static_cast<uint8_t>(TrackTileType::CornerBR);
149 }
150
151 if (count == 3) {
152 // Oracle names T junctions for the CLOSED side. Its minecart input and
153 // default-direction tables turn away from that edge (e.g. $BB blocks north).
154 if (!up)
155 return static_cast<uint8_t>(TrackTileType::TJuncNorth);
156 if (!down)
157 return static_cast<uint8_t>(TrackTileType::TJuncSouth);
158 if (!left)
159 return static_cast<uint8_t>(TrackTileType::TJuncWest);
160 if (!right)
161 return static_cast<uint8_t>(TrackTileType::TJuncEast);
162 }
163
164 // count == 4: full intersection
165 return static_cast<uint8_t>(TrackTileType::Intersection);
166}
167
168// Get the tile label character for ASCII visualization.
169char TileToChar(uint8_t tile) {
170 switch (tile) {
171 case 0xB0:
172 return '-'; // horiz straight
173 case 0xB1:
174 return '|'; // vert straight
175 case 0xB2:
176 return '/'; // corner TL (down+right)
177 case 0xB3:
178 return '\\'; // corner BL (up+right)
179 case 0xB4:
180 return '\\'; // corner TR (down+left)
181 case 0xB5:
182 return '/'; // corner BR (up+left)
183 case 0xB6:
184 return '+'; // intersection
185 case 0xB7:
186 return 'N'; // stop north
187 case 0xB8:
188 return 'S'; // stop south
189 case 0xB9:
190 return 'W'; // stop west
191 case 0xBA:
192 return 'E'; // stop east
193 case 0xBB:
194 return 'T'; // T-junc north
195 case 0xBC:
196 return 'T'; // T-junc south
197 case 0xBD:
198 return 'T'; // T-junc east
199 case 0xBE:
200 return 'T'; // T-junc west
201 case 0xD0:
202 return '@'; // switch TL
203 case 0xD1:
204 return '@'; // switch BL
205 case 0xD2:
206 return '@'; // switch TR
207 case 0xD3:
208 return '@'; // switch BR
209 default:
210 return '.';
211 }
212}
213
215 const RoomObject& obj, const GeneratorOptions& options,
216 const DimensionService& dimension_service) {
217 auto dims = dimension_service.GetDimensions(obj);
218
219 // Object 0x31's size byte selects a custom-object subtype; it is not a
220 // vanilla width/height nibble. Without project custom-object geometry,
221 // DimensionService falls through to that vanilla interpretation and gives
222 // non-zero subtypes incorrect variable-width footprints. Oracle's track
223 // assets are all authored as 2x2 pieces, so use that canonical footprint in
224 // the project-free CLI path. Configured non-0x31 track IDs continue to use
225 // DimensionService normally.
226 const bool is_canonical_oos_track =
227 obj.id_ == 0x31 &&
228 obj.id_ == static_cast<int16_t>(options.track_object_id);
229 if (is_canonical_oos_track &&
231 dims.offset_x_tiles = 0;
232 dims.offset_y_tiles = 0;
233 dims.width_tiles = kFallbackTrackFootprintWidthTiles;
234 dims.height_tiles = kFallbackTrackFootprintHeightTiles;
235 return dims;
236 }
237
238 // Also recover when custom objects are enabled but a configured subtype has
239 // no loaded payload and therefore measures as the 1x1 fallback.
240 if (is_canonical_oos_track && dims.offset_x_tiles == 0 &&
241 dims.offset_y_tiles == 0 && dims.width_tiles == 1 &&
242 dims.height_tiles == 1) {
243 dims.width_tiles = kFallbackTrackFootprintWidthTiles;
244 dims.height_tiles = kFallbackTrackFootprintHeightTiles;
245 }
246
247 return dims;
248}
249
250} // namespace
251
252absl::StatusOr<TrackCollisionResult> GenerateTrackCollision(
253 Room* room, const GeneratorOptions& options) {
254 if (!room) {
255 return absl::InvalidArgumentError("Room pointer is null");
256 }
257
258 // An empty object list can be a valid unsaved edit. The explicit loaded flag
259 // is the only safe authority for deciding whether ROM data must be parsed.
260 room->EnsureObjectsLoaded();
261
263 result.room_id = room->id();
264 result.collision_map.tiles.fill(0);
265
266 // Step 1: Build occupancy grid from rail objects.
267 // Rail objects (ID 0x31) have coordinates in the room's tile space.
268 // RoomObject x_ and y_ are in tile coordinates (each tile = 8 pixels).
269 // The collision grid is 64x64 (covering 512x512 pixels = full room).
270 std::array<bool, kGridSize * kGridSize> occupied{};
271 auto& dimension_service = DimensionService::Get();
272
273 for (const auto& obj : room->GetTileObjects()) {
274 if (obj.id_ != static_cast<int16_t>(options.track_object_id)) {
275 continue;
276 }
277 const int subtype = obj.size_ & 0x1F;
278 if (!IsMinecartTrackGraphicsSubtype(options.track_object_id, subtype)) {
279 continue;
280 }
281
282 const auto dims =
283 ResolveTrackObjectDimensions(obj, options, dimension_service);
284 int base_x = obj.x_ + dims.offset_x_tiles;
285 int base_y = obj.y_ + dims.offset_y_tiles;
286 int w = std::max(1, dims.width_tiles);
287 int h = std::max(1, dims.height_tiles);
288
289 for (int dy = 0; dy < h; ++dy) {
290 for (int dx = 0; dx < w; ++dx) {
291 int gx = base_x + dx;
292 int gy = base_y + dy;
293 if (gx >= 0 && gx < kGridSize && gy >= 0 && gy < kGridSize) {
294 occupied[gy * kGridSize + gx] = true;
295 }
296 }
297 }
298 }
299
300 // Step 2: Classify each occupied tile by neighbor connectivity.
301 for (int y = 0; y < kGridSize; ++y) {
302 for (int x = 0; x < kGridSize; ++x) {
303 if (!occupied[y * kGridSize + x])
304 continue;
305
306 bool up = (y > 0) && occupied[(y - 1) * kGridSize + x];
307 bool down = (y < kGridSize - 1) && occupied[(y + 1) * kGridSize + x];
308 bool left = (x > 0) && occupied[y * kGridSize + (x - 1)];
309 bool right = (x < kGridSize - 1) && occupied[y * kGridSize + (x + 1)];
310
311 uint8_t tile = ClassifyTile(up, down, left, right);
312 result.collision_map.tiles[y * kGridSize + x] = tile;
313 result.tiles_generated++;
314
315 if (tile >= 0xB7 && tile <= 0xBA)
316 result.stop_count++;
317 if (tile >= 0xB2 && tile <= 0xB5)
318 result.corner_count++;
319 }
320 }
321
322 // Step 3: Apply switch promotions.
323 for (const auto& [sx, sy] : options.switch_promotions) {
324 if (sx < 0 || sx >= kGridSize || sy < 0 || sy >= kGridSize)
325 continue;
326 size_t idx = sy * kGridSize + sx;
327 uint8_t tile = result.collision_map.tiles[idx];
328 if (IsCornerTile(tile)) {
329 result.collision_map.tiles[idx] = static_cast<uint8_t>(
330 PromoteCornerToSwitch(static_cast<TrackTileType>(tile)));
331 result.corner_count--;
332 result.switch_count++;
333 }
334 }
335
336 // Step 4: Apply manual stop overrides.
337 for (const auto& [ox, oy, otype] : options.stop_overrides) {
338 if (ox < 0 || ox >= kGridSize || oy < 0 || oy >= kGridSize)
339 continue;
340 size_t idx = oy * kGridSize + ox;
341 if (result.collision_map.tiles[idx] != 0) {
342 result.collision_map.tiles[idx] = static_cast<uint8_t>(otype);
343 }
344 }
345
346 result.collision_map.has_data = (result.tiles_generated > 0);
348
349 return result;
350}
351
352absl::Status WriteTrackCollision(Rom* rom, int room_id,
353 const CustomCollisionMap& map) {
354 if (!rom || !rom->is_loaded()) {
355 return absl::InvalidArgumentError("ROM not loaded");
356 }
357 if (room_id < 0 || room_id >= kNumberOfRooms) {
358 return absl::OutOfRangeError("Room ID out of range");
359 }
360
361 const auto& data = rom->vector();
362 if (data.empty()) {
363 return absl::FailedPreconditionError("ROM vector is empty");
364 }
365
366 const int ptrs_size = kNumberOfRooms * 3;
367 if (kCustomCollisionRoomPointers + ptrs_size >
368 static_cast<int>(data.size())) {
369 return absl::FailedPreconditionError(
370 "Custom collision pointer table not present in this ROM");
371 }
372 if (kCustomCollisionDataPosition >= static_cast<int>(data.size())) {
373 return absl::FailedPreconditionError(
374 "Custom collision data region not present in this ROM");
375 }
376 if (kCustomCollisionDataSoftEnd > static_cast<int>(data.size())) {
377 return absl::FailedPreconditionError(
378 "Custom collision data region truncated (ROM too small)");
379 }
380
381 // Save-time guardrails: only allow writes to the collision pointer table and
382 // collision data bank (excluding the reserved WaterFill tail region).
385 static_cast<uint32_t>(kCustomCollisionRoomPointers),
386 static_cast<uint32_t>(kCustomCollisionRoomPointers + ptrs_size),
387 "CustomCollisionPointers"));
389 fence.Allow(static_cast<uint32_t>(kCustomCollisionDataPosition),
390 static_cast<uint32_t>(kCustomCollisionDataSoftEnd),
391 "CustomCollisionData"));
392 yaze::rom::ScopedWriteFence scope(rom, &fence);
393
394 // Encode collision data in single-tile format.
395 // Format: [F0 F0] [offset_lo offset_hi tile] ... [FF FF]
396 std::vector<uint8_t> encoded;
397 encoded.push_back(0xF0);
398 encoded.push_back(0xF0);
399
400 for (int y = 0; y < kGridSize; ++y) {
401 for (int x = 0; x < kGridSize; ++x) {
402 uint8_t tile = map.tiles[y * kGridSize + x];
403 if (!map.HasOverride(y * kGridSize + x))
404 continue;
405 uint16_t offset = static_cast<uint16_t>(y * kGridSize + x);
406 encoded.push_back(offset & 0xFF);
407 encoded.push_back(offset >> 8);
408 encoded.push_back(tile);
409 }
410 }
411 encoded.push_back(0xFF);
412 encoded.push_back(0xFF);
413
414 // Find the end of existing collision data by scanning all room pointers
415 // to determine the highest used offset.
416 const size_t safe_end =
417 std::min(static_cast<size_t>(data.size()),
418 static_cast<size_t>(kCustomCollisionDataSoftEnd));
419 uint32_t max_used_pc = static_cast<uint32_t>(kCustomCollisionDataPosition);
420 std::vector<CollisionBlob> blobs;
421 blobs.reserve(kNumberOfRooms);
422 for (int r = 0; r < kNumberOfRooms; ++r) {
423 int ptr_offset = kCustomCollisionRoomPointers + (r * 3);
424 if (ptr_offset + 2 >= static_cast<int>(data.size()))
425 continue;
426
427 uint32_t snes_ptr = data[ptr_offset] | (data[ptr_offset + 1] << 8) |
428 (data[ptr_offset + 2] << 16);
429 if (snes_ptr == 0)
430 continue;
431
432 uint32_t pc = SnesToPc(snes_ptr);
433 if (pc < static_cast<uint32_t>(kCustomCollisionDataPosition)) {
434 return absl::FailedPreconditionError(
435 absl::StrFormat("Custom collision pointer for room 0x%02X points "
436 "before data region (pc=0x%06X)",
437 r, pc));
438 }
439 if (pc >= static_cast<uint32_t>(kCustomCollisionDataSoftEnd)) {
440 return absl::FailedPreconditionError(
441 absl::StrFormat("Custom collision pointer for room 0x%02X overlaps "
442 "WaterFill reserved region (pc=0x%06X)",
443 r, pc));
444 }
445 if (pc >= data.size()) {
446 return absl::OutOfRangeError("Custom collision pointer out of ROM range");
447 }
448 ASSIGN_OR_RETURN(const uint32_t end_pc,
449 FindCollisionBlobEnd(data, pc, safe_end, r));
450 blobs.push_back(CollisionBlob{r, pc, end_pc});
451 if (end_pc > max_used_pc) {
452 max_used_pc = end_pc;
453 }
454 }
455
456 // Reuse the selected room's current blob only when its entire physical span
457 // is uniquely owned and the replacement fits. Aliased or overlapping blobs
458 // remain copy-on-write so editing one room cannot mutate another room.
459 uint32_t write_pos = max_used_pc;
460 const auto target = std::find_if(
461 blobs.begin(), blobs.end(),
462 [room_id](const CollisionBlob& blob) { return blob.room_id == room_id; });
463 if (target != blobs.end() && encoded.size() <= target->end - target->start) {
464 const bool overlaps_other = std::any_of(
465 blobs.begin(), blobs.end(), [&](const CollisionBlob& other) {
466 return other.room_id != room_id && BlobsOverlap(*target, other);
467 });
468 if (!overlaps_other) {
469 write_pos = target->start;
470 }
471 }
472
473 // Append when there is no safe reusable span, then check available space.
474 if (write_pos + encoded.size() > kCustomCollisionDataSoftEnd) {
475 return absl::ResourceExhaustedError(absl::StrFormat(
476 "Not enough collision data space. Need %d bytes at 0x%06X, "
477 "region ends at 0x%06X",
478 encoded.size(), write_pos, kCustomCollisionDataSoftEnd));
479 }
480
481 if (write_pos + encoded.size() > data.size()) {
482 return absl::OutOfRangeError(
483 absl::StrFormat("ROM too small for custom collision write (need "
484 "end=0x%06X, size=0x%06X)",
485 write_pos + encoded.size(), data.size()));
486 }
488 rom->WriteVector(static_cast<int>(write_pos), std::move(encoded)));
489
490 // Update pointer table: 3-byte SNES address
491 uint32_t snes_addr = PcToSnes(write_pos);
492 int ptr_offset = kCustomCollisionRoomPointers + (room_id * 3);
493 RETURN_IF_ERROR(rom->WriteByte(ptr_offset, snes_addr & 0xFF));
494 RETURN_IF_ERROR(rom->WriteByte(ptr_offset + 1, (snes_addr >> 8) & 0xFF));
495 RETURN_IF_ERROR(rom->WriteByte(ptr_offset + 2, (snes_addr >> 16) & 0xFF));
496
497 return absl::OkStatus();
498}
499
501 // Find bounding box of non-zero tiles to avoid printing the entire 64x64.
502 int min_x = kGridSize, max_x = 0, min_y = kGridSize, max_y = 0;
503 for (int y = 0; y < kGridSize; ++y) {
504 for (int x = 0; x < kGridSize; ++x) {
505 if (map.tiles[y * kGridSize + x] != 0) {
506 min_x = std::min(min_x, x);
507 max_x = std::max(max_x, x);
508 min_y = std::min(min_y, y);
509 max_y = std::max(max_y, y);
510 }
511 }
512 }
513
514 if (min_x > max_x)
515 return "(empty)\n";
516
517 // Add 1-tile padding
518 min_x = std::max(0, min_x - 1);
519 min_y = std::max(0, min_y - 1);
520 max_x = std::min(kGridSize - 1, max_x + 1);
521 max_y = std::min(kGridSize - 1, max_y + 1);
522
523 std::stringstream ss;
524 // Column header
525 ss << " ";
526 for (int x = min_x; x <= max_x; ++x) {
527 ss << absl::StrFormat("%X", x % 16);
528 }
529 ss << "\n";
530
531 for (int y = min_y; y <= max_y; ++y) {
532 ss << absl::StrFormat("%02X: ", y);
533 for (int x = min_x; x <= max_x; ++x) {
534 uint8_t tile = map.tiles[y * kGridSize + x];
535 ss << TileToChar(tile);
536 }
537 ss << "\n";
538 }
539
540 return ss.str();
541}
542
543} // namespace zelda3
544} // namespace yaze
The Rom class is used to load, save, and modify Rom data. This is a generic SNES ROM container and do...
Definition rom.h:49
absl::Status WriteByte(int addr, uint8_t value)
Definition rom.cc:853
const auto & vector() const
Definition rom.h:205
absl::Status WriteVector(int addr, std::vector< uint8_t > data)
Definition rom.cc:928
bool is_loaded() const
Definition rom.h:187
static Flags & get()
Definition features.h:119
absl::Status Allow(uint32_t start, uint32_t end, std::string_view label)
Definition write_fence.h:32
Unified dimension lookup for dungeon room objects.
static DimensionService & Get()
DimensionResult GetDimensions(const RoomObject &obj) const
const std::vector< RoomObject > & GetTileObjects() const
Definition room.h:445
void EnsureObjectsLoaded()
Definition room.cc:1026
int id() const
Definition room.h:1012
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
bool BlobsOverlap(const CollisionBlob &lhs, const CollisionBlob &rhs)
uint8_t ClassifyTile(bool up, bool down, bool left, bool right)
absl::StatusOr< uint32_t > FindCollisionBlobEnd(const std::vector< uint8_t > &data, uint32_t start, size_t safe_end, int room_id)
DimensionService::DimensionResult ResolveTrackObjectDimensions(const RoomObject &obj, const GeneratorOptions &options, const DimensionService &dimension_service)
absl::Status WriteTrackCollision(Rom *rom, int room_id, const CustomCollisionMap &map)
constexpr int kCustomCollisionDataSoftEnd
std::string VisualizeCollisionMap(const CustomCollisionMap &map)
constexpr int kCustomCollisionDataPosition
constexpr int kNumberOfRooms
constexpr int kCustomCollisionRoomPointers
bool IsMinecartTrackGraphicsSubtype(int object_id, int subtype)
absl::StatusOr< TrackCollisionResult > GenerateTrackCollision(Room *room, const GeneratorOptions &options)
uint32_t PcToSnes(uint32_t addr)
Definition snes.h:17
uint32_t SnesToPc(uint32_t addr) noexcept
Definition snes.h:8
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
std::array< uint8_t, 64 *64 > tiles
bool HasOverride(size_t offset) const
std::vector< std::pair< int, int > > switch_promotions
std::vector< std::tuple< int, int, TrackTileType > > stop_overrides