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 // T-junction — named for the direction WITHOUT a neighbor
153 if (!up)
154 return static_cast<uint8_t>(TrackTileType::TJuncSouth);
155 if (!down)
156 return static_cast<uint8_t>(TrackTileType::TJuncNorth);
157 if (!left)
158 return static_cast<uint8_t>(TrackTileType::TJuncEast);
159 if (!right)
160 return static_cast<uint8_t>(TrackTileType::TJuncWest);
161 }
162
163 // count == 4: full intersection
164 return static_cast<uint8_t>(TrackTileType::Intersection);
165}
166
167// Get the tile label character for ASCII visualization.
168char TileToChar(uint8_t tile) {
169 switch (tile) {
170 case 0xB0:
171 return '-'; // horiz straight
172 case 0xB1:
173 return '|'; // vert straight
174 case 0xB2:
175 return '/'; // corner TL (down+right)
176 case 0xB3:
177 return '\\'; // corner BL (up+right)
178 case 0xB4:
179 return '\\'; // corner TR (down+left)
180 case 0xB5:
181 return '/'; // corner BR (up+left)
182 case 0xB6:
183 return '+'; // intersection
184 case 0xB7:
185 return 'N'; // stop north
186 case 0xB8:
187 return 'S'; // stop south
188 case 0xB9:
189 return 'W'; // stop west
190 case 0xBA:
191 return 'E'; // stop east
192 case 0xBB:
193 return 'T'; // T-junc north
194 case 0xBC:
195 return 'T'; // T-junc south
196 case 0xBD:
197 return 'T'; // T-junc east
198 case 0xBE:
199 return 'T'; // T-junc west
200 case 0xD0:
201 return '@'; // switch TL
202 case 0xD1:
203 return '@'; // switch BL
204 case 0xD2:
205 return '@'; // switch TR
206 case 0xD3:
207 return '@'; // switch BR
208 default:
209 return '.';
210 }
211}
212
214 const RoomObject& obj, const GeneratorOptions& options,
215 const DimensionService& dimension_service) {
216 auto dims = dimension_service.GetDimensions(obj);
217
218 // Object 0x31's size byte selects a custom-object subtype; it is not a
219 // vanilla width/height nibble. Without project custom-object geometry,
220 // DimensionService falls through to that vanilla interpretation and gives
221 // non-zero subtypes incorrect variable-width footprints. Oracle's track
222 // assets are all authored as 2x2 pieces, so use that canonical footprint in
223 // the project-free CLI path. Configured non-0x31 track IDs continue to use
224 // DimensionService normally.
225 const bool is_canonical_oos_track =
226 obj.id_ == 0x31 &&
227 obj.id_ == static_cast<int16_t>(options.track_object_id);
228 if (is_canonical_oos_track &&
230 dims.offset_x_tiles = 0;
231 dims.offset_y_tiles = 0;
232 dims.width_tiles = kFallbackTrackFootprintWidthTiles;
233 dims.height_tiles = kFallbackTrackFootprintHeightTiles;
234 return dims;
235 }
236
237 // Also recover when custom objects are enabled but a configured subtype has
238 // no loaded payload and therefore measures as the 1x1 fallback.
239 if (is_canonical_oos_track && dims.offset_x_tiles == 0 &&
240 dims.offset_y_tiles == 0 && dims.width_tiles == 1 &&
241 dims.height_tiles == 1) {
242 dims.width_tiles = kFallbackTrackFootprintWidthTiles;
243 dims.height_tiles = kFallbackTrackFootprintHeightTiles;
244 }
245
246 return dims;
247}
248
249} // namespace
250
251absl::StatusOr<TrackCollisionResult> GenerateTrackCollision(
252 Room* room, const GeneratorOptions& options) {
253 if (!room) {
254 return absl::InvalidArgumentError("Room pointer is null");
255 }
256
257 // Ensure objects are loaded
258 if (room->GetTileObjects().empty()) {
259 room->LoadObjects();
260 }
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
278 const auto dims =
279 ResolveTrackObjectDimensions(obj, options, dimension_service);
280 int base_x = obj.x_ + dims.offset_x_tiles;
281 int base_y = obj.y_ + dims.offset_y_tiles;
282 int w = std::max(1, dims.width_tiles);
283 int h = std::max(1, dims.height_tiles);
284
285 for (int dy = 0; dy < h; ++dy) {
286 for (int dx = 0; dx < w; ++dx) {
287 int gx = base_x + dx;
288 int gy = base_y + dy;
289 if (gx >= 0 && gx < kGridSize && gy >= 0 && gy < kGridSize) {
290 occupied[gy * kGridSize + gx] = true;
291 }
292 }
293 }
294 }
295
296 // Step 2: Classify each occupied tile by neighbor connectivity.
297 for (int y = 0; y < kGridSize; ++y) {
298 for (int x = 0; x < kGridSize; ++x) {
299 if (!occupied[y * kGridSize + x])
300 continue;
301
302 bool up = (y > 0) && occupied[(y - 1) * kGridSize + x];
303 bool down = (y < kGridSize - 1) && occupied[(y + 1) * kGridSize + x];
304 bool left = (x > 0) && occupied[y * kGridSize + (x - 1)];
305 bool right = (x < kGridSize - 1) && occupied[y * kGridSize + (x + 1)];
306
307 uint8_t tile = ClassifyTile(up, down, left, right);
308 result.collision_map.tiles[y * kGridSize + x] = tile;
309 result.tiles_generated++;
310
311 if (tile >= 0xB7 && tile <= 0xBA)
312 result.stop_count++;
313 if (tile >= 0xB2 && tile <= 0xB5)
314 result.corner_count++;
315 }
316 }
317
318 // Step 3: Apply switch promotions.
319 for (const auto& [sx, sy] : options.switch_promotions) {
320 if (sx < 0 || sx >= kGridSize || sy < 0 || sy >= kGridSize)
321 continue;
322 size_t idx = sy * kGridSize + sx;
323 uint8_t tile = result.collision_map.tiles[idx];
324 if (IsCornerTile(tile)) {
325 result.collision_map.tiles[idx] = static_cast<uint8_t>(
326 PromoteCornerToSwitch(static_cast<TrackTileType>(tile)));
327 result.corner_count--;
328 result.switch_count++;
329 }
330 }
331
332 // Step 4: Apply manual stop overrides.
333 for (const auto& [ox, oy, otype] : options.stop_overrides) {
334 if (ox < 0 || ox >= kGridSize || oy < 0 || oy >= kGridSize)
335 continue;
336 size_t idx = oy * kGridSize + ox;
337 if (result.collision_map.tiles[idx] != 0) {
338 result.collision_map.tiles[idx] = static_cast<uint8_t>(otype);
339 }
340 }
341
342 result.collision_map.has_data = (result.tiles_generated > 0);
344
345 return result;
346}
347
348absl::Status WriteTrackCollision(Rom* rom, int room_id,
349 const CustomCollisionMap& map) {
350 if (!rom || !rom->is_loaded()) {
351 return absl::InvalidArgumentError("ROM not loaded");
352 }
353 if (room_id < 0 || room_id >= kNumberOfRooms) {
354 return absl::OutOfRangeError("Room ID out of range");
355 }
356
357 const auto& data = rom->vector();
358 if (data.empty()) {
359 return absl::FailedPreconditionError("ROM vector is empty");
360 }
361
362 const int ptrs_size = kNumberOfRooms * 3;
363 if (kCustomCollisionRoomPointers + ptrs_size >
364 static_cast<int>(data.size())) {
365 return absl::FailedPreconditionError(
366 "Custom collision pointer table not present in this ROM");
367 }
368 if (kCustomCollisionDataPosition >= static_cast<int>(data.size())) {
369 return absl::FailedPreconditionError(
370 "Custom collision data region not present in this ROM");
371 }
372 if (kCustomCollisionDataSoftEnd > static_cast<int>(data.size())) {
373 return absl::FailedPreconditionError(
374 "Custom collision data region truncated (ROM too small)");
375 }
376
377 // Save-time guardrails: only allow writes to the collision pointer table and
378 // collision data bank (excluding the reserved WaterFill tail region).
381 static_cast<uint32_t>(kCustomCollisionRoomPointers),
382 static_cast<uint32_t>(kCustomCollisionRoomPointers + ptrs_size),
383 "CustomCollisionPointers"));
385 fence.Allow(static_cast<uint32_t>(kCustomCollisionDataPosition),
386 static_cast<uint32_t>(kCustomCollisionDataSoftEnd),
387 "CustomCollisionData"));
388 yaze::rom::ScopedWriteFence scope(rom, &fence);
389
390 // Encode collision data in single-tile format.
391 // Format: [F0 F0] [offset_lo offset_hi tile] ... [FF FF]
392 std::vector<uint8_t> encoded;
393 encoded.push_back(0xF0);
394 encoded.push_back(0xF0);
395
396 for (int y = 0; y < kGridSize; ++y) {
397 for (int x = 0; x < kGridSize; ++x) {
398 uint8_t tile = map.tiles[y * kGridSize + x];
399 if (tile == 0)
400 continue;
401 uint16_t offset = static_cast<uint16_t>(y * kGridSize + x);
402 encoded.push_back(offset & 0xFF);
403 encoded.push_back(offset >> 8);
404 encoded.push_back(tile);
405 }
406 }
407 encoded.push_back(0xFF);
408 encoded.push_back(0xFF);
409
410 // Find the end of existing collision data by scanning all room pointers
411 // to determine the highest used offset.
412 const size_t safe_end =
413 std::min(static_cast<size_t>(data.size()),
414 static_cast<size_t>(kCustomCollisionDataSoftEnd));
415 uint32_t max_used_pc = static_cast<uint32_t>(kCustomCollisionDataPosition);
416 std::vector<CollisionBlob> blobs;
417 blobs.reserve(kNumberOfRooms);
418 for (int r = 0; r < kNumberOfRooms; ++r) {
419 int ptr_offset = kCustomCollisionRoomPointers + (r * 3);
420 if (ptr_offset + 2 >= static_cast<int>(data.size()))
421 continue;
422
423 uint32_t snes_ptr = data[ptr_offset] | (data[ptr_offset + 1] << 8) |
424 (data[ptr_offset + 2] << 16);
425 if (snes_ptr == 0)
426 continue;
427
428 uint32_t pc = SnesToPc(snes_ptr);
429 if (pc < static_cast<uint32_t>(kCustomCollisionDataPosition)) {
430 return absl::FailedPreconditionError(
431 absl::StrFormat("Custom collision pointer for room 0x%02X points "
432 "before data region (pc=0x%06X)",
433 r, pc));
434 }
435 if (pc >= static_cast<uint32_t>(kCustomCollisionDataSoftEnd)) {
436 return absl::FailedPreconditionError(
437 absl::StrFormat("Custom collision pointer for room 0x%02X overlaps "
438 "WaterFill reserved region (pc=0x%06X)",
439 r, pc));
440 }
441 if (pc >= data.size()) {
442 return absl::OutOfRangeError("Custom collision pointer out of ROM range");
443 }
444 ASSIGN_OR_RETURN(const uint32_t end_pc,
445 FindCollisionBlobEnd(data, pc, safe_end, r));
446 blobs.push_back(CollisionBlob{r, pc, end_pc});
447 if (end_pc > max_used_pc) {
448 max_used_pc = end_pc;
449 }
450 }
451
452 // Reuse the selected room's current blob only when its entire physical span
453 // is uniquely owned and the replacement fits. Aliased or overlapping blobs
454 // remain copy-on-write so editing one room cannot mutate another room.
455 uint32_t write_pos = max_used_pc;
456 const auto target = std::find_if(
457 blobs.begin(), blobs.end(),
458 [room_id](const CollisionBlob& blob) { return blob.room_id == room_id; });
459 if (target != blobs.end() && encoded.size() <= target->end - target->start) {
460 const bool overlaps_other = std::any_of(
461 blobs.begin(), blobs.end(), [&](const CollisionBlob& other) {
462 return other.room_id != room_id && BlobsOverlap(*target, other);
463 });
464 if (!overlaps_other) {
465 write_pos = target->start;
466 }
467 }
468
469 // Append when there is no safe reusable span, then check available space.
470 if (write_pos + encoded.size() > kCustomCollisionDataSoftEnd) {
471 return absl::ResourceExhaustedError(absl::StrFormat(
472 "Not enough collision data space. Need %d bytes at 0x%06X, "
473 "region ends at 0x%06X",
474 encoded.size(), write_pos, kCustomCollisionDataSoftEnd));
475 }
476
477 if (write_pos + encoded.size() > data.size()) {
478 return absl::OutOfRangeError(
479 absl::StrFormat("ROM too small for custom collision write (need "
480 "end=0x%06X, size=0x%06X)",
481 write_pos + encoded.size(), data.size()));
482 }
484 rom->WriteVector(static_cast<int>(write_pos), std::move(encoded)));
485
486 // Update pointer table: 3-byte SNES address
487 uint32_t snes_addr = PcToSnes(write_pos);
488 int ptr_offset = kCustomCollisionRoomPointers + (room_id * 3);
489 RETURN_IF_ERROR(rom->WriteByte(ptr_offset, snes_addr & 0xFF));
490 RETURN_IF_ERROR(rom->WriteByte(ptr_offset + 1, (snes_addr >> 8) & 0xFF));
491 RETURN_IF_ERROR(rom->WriteByte(ptr_offset + 2, (snes_addr >> 16) & 0xFF));
492
493 return absl::OkStatus();
494}
495
497 // Find bounding box of non-zero tiles to avoid printing the entire 64x64.
498 int min_x = kGridSize, max_x = 0, min_y = kGridSize, max_y = 0;
499 for (int y = 0; y < kGridSize; ++y) {
500 for (int x = 0; x < kGridSize; ++x) {
501 if (map.tiles[y * kGridSize + x] != 0) {
502 min_x = std::min(min_x, x);
503 max_x = std::max(max_x, x);
504 min_y = std::min(min_y, y);
505 max_y = std::max(max_y, y);
506 }
507 }
508 }
509
510 if (min_x > max_x)
511 return "(empty)\n";
512
513 // Add 1-tile padding
514 min_x = std::max(0, min_x - 1);
515 min_y = std::max(0, min_y - 1);
516 max_x = std::min(kGridSize - 1, max_x + 1);
517 max_y = std::min(kGridSize - 1, max_y + 1);
518
519 std::stringstream ss;
520 // Column header
521 ss << " ";
522 for (int x = min_x; x <= max_x; ++x) {
523 ss << absl::StrFormat("%X", x % 16);
524 }
525 ss << "\n";
526
527 for (int y = min_y; y <= max_y; ++y) {
528 ss << absl::StrFormat("%02X: ", y);
529 for (int x = min_x; x <= max_x; ++x) {
530 uint8_t tile = map.tiles[y * kGridSize + x];
531 ss << TileToChar(tile);
532 }
533 ss << "\n";
534 }
535
536 return ss.str();
537}
538
539} // namespace zelda3
540} // 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:28
absl::Status WriteByte(int addr, uint8_t value)
Definition rom.cc:631
const auto & vector() const
Definition rom.h:173
absl::Status WriteVector(int addr, std::vector< uint8_t > data)
Definition rom.cc:703
bool is_loaded() const
Definition rom.h:155
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:383
void LoadObjects()
Definition room.cc:1625
int id() const
Definition room.h:900
#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
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
std::vector< std::pair< int, int > > switch_promotions
std::vector< std::tuple< int, int, TrackTileType > > stop_overrides