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 // An empty object list can be a valid unsaved edit. The explicit loaded flag
258 // is the only safe authority for deciding whether ROM data must be parsed.
259 room->EnsureObjectsLoaded();
260
262 result.room_id = room->id();
263 result.collision_map.tiles.fill(0);
264
265 // Step 1: Build occupancy grid from rail objects.
266 // Rail objects (ID 0x31) have coordinates in the room's tile space.
267 // RoomObject x_ and y_ are in tile coordinates (each tile = 8 pixels).
268 // The collision grid is 64x64 (covering 512x512 pixels = full room).
269 std::array<bool, kGridSize * kGridSize> occupied{};
270 auto& dimension_service = DimensionService::Get();
271
272 for (const auto& obj : room->GetTileObjects()) {
273 if (obj.id_ != static_cast<int16_t>(options.track_object_id)) {
274 continue;
275 }
276 const int subtype = obj.size_ & 0x1F;
277 if (!IsMinecartTrackGraphicsSubtype(options.track_object_id, subtype)) {
278 continue;
279 }
280
281 const auto dims =
282 ResolveTrackObjectDimensions(obj, options, dimension_service);
283 int base_x = obj.x_ + dims.offset_x_tiles;
284 int base_y = obj.y_ + dims.offset_y_tiles;
285 int w = std::max(1, dims.width_tiles);
286 int h = std::max(1, dims.height_tiles);
287
288 for (int dy = 0; dy < h; ++dy) {
289 for (int dx = 0; dx < w; ++dx) {
290 int gx = base_x + dx;
291 int gy = base_y + dy;
292 if (gx >= 0 && gx < kGridSize && gy >= 0 && gy < kGridSize) {
293 occupied[gy * kGridSize + gx] = true;
294 }
295 }
296 }
297 }
298
299 // Step 2: Classify each occupied tile by neighbor connectivity.
300 for (int y = 0; y < kGridSize; ++y) {
301 for (int x = 0; x < kGridSize; ++x) {
302 if (!occupied[y * kGridSize + x])
303 continue;
304
305 bool up = (y > 0) && occupied[(y - 1) * kGridSize + x];
306 bool down = (y < kGridSize - 1) && occupied[(y + 1) * kGridSize + x];
307 bool left = (x > 0) && occupied[y * kGridSize + (x - 1)];
308 bool right = (x < kGridSize - 1) && occupied[y * kGridSize + (x + 1)];
309
310 uint8_t tile = ClassifyTile(up, down, left, right);
311 result.collision_map.tiles[y * kGridSize + x] = tile;
312 result.tiles_generated++;
313
314 if (tile >= 0xB7 && tile <= 0xBA)
315 result.stop_count++;
316 if (tile >= 0xB2 && tile <= 0xB5)
317 result.corner_count++;
318 }
319 }
320
321 // Step 3: Apply switch promotions.
322 for (const auto& [sx, sy] : options.switch_promotions) {
323 if (sx < 0 || sx >= kGridSize || sy < 0 || sy >= kGridSize)
324 continue;
325 size_t idx = sy * kGridSize + sx;
326 uint8_t tile = result.collision_map.tiles[idx];
327 if (IsCornerTile(tile)) {
328 result.collision_map.tiles[idx] = static_cast<uint8_t>(
329 PromoteCornerToSwitch(static_cast<TrackTileType>(tile)));
330 result.corner_count--;
331 result.switch_count++;
332 }
333 }
334
335 // Step 4: Apply manual stop overrides.
336 for (const auto& [ox, oy, otype] : options.stop_overrides) {
337 if (ox < 0 || ox >= kGridSize || oy < 0 || oy >= kGridSize)
338 continue;
339 size_t idx = oy * kGridSize + ox;
340 if (result.collision_map.tiles[idx] != 0) {
341 result.collision_map.tiles[idx] = static_cast<uint8_t>(otype);
342 }
343 }
344
345 result.collision_map.has_data = (result.tiles_generated > 0);
347
348 return result;
349}
350
351absl::Status WriteTrackCollision(Rom* rom, int room_id,
352 const CustomCollisionMap& map) {
353 if (!rom || !rom->is_loaded()) {
354 return absl::InvalidArgumentError("ROM not loaded");
355 }
356 if (room_id < 0 || room_id >= kNumberOfRooms) {
357 return absl::OutOfRangeError("Room ID out of range");
358 }
359
360 const auto& data = rom->vector();
361 if (data.empty()) {
362 return absl::FailedPreconditionError("ROM vector is empty");
363 }
364
365 const int ptrs_size = kNumberOfRooms * 3;
366 if (kCustomCollisionRoomPointers + ptrs_size >
367 static_cast<int>(data.size())) {
368 return absl::FailedPreconditionError(
369 "Custom collision pointer table not present in this ROM");
370 }
371 if (kCustomCollisionDataPosition >= static_cast<int>(data.size())) {
372 return absl::FailedPreconditionError(
373 "Custom collision data region not present in this ROM");
374 }
375 if (kCustomCollisionDataSoftEnd > static_cast<int>(data.size())) {
376 return absl::FailedPreconditionError(
377 "Custom collision data region truncated (ROM too small)");
378 }
379
380 // Save-time guardrails: only allow writes to the collision pointer table and
381 // collision data bank (excluding the reserved WaterFill tail region).
384 static_cast<uint32_t>(kCustomCollisionRoomPointers),
385 static_cast<uint32_t>(kCustomCollisionRoomPointers + ptrs_size),
386 "CustomCollisionPointers"));
388 fence.Allow(static_cast<uint32_t>(kCustomCollisionDataPosition),
389 static_cast<uint32_t>(kCustomCollisionDataSoftEnd),
390 "CustomCollisionData"));
391 yaze::rom::ScopedWriteFence scope(rom, &fence);
392
393 // Encode collision data in single-tile format.
394 // Format: [F0 F0] [offset_lo offset_hi tile] ... [FF FF]
395 std::vector<uint8_t> encoded;
396 encoded.push_back(0xF0);
397 encoded.push_back(0xF0);
398
399 for (int y = 0; y < kGridSize; ++y) {
400 for (int x = 0; x < kGridSize; ++x) {
401 uint8_t tile = map.tiles[y * kGridSize + x];
402 if (tile == 0)
403 continue;
404 uint16_t offset = static_cast<uint16_t>(y * kGridSize + x);
405 encoded.push_back(offset & 0xFF);
406 encoded.push_back(offset >> 8);
407 encoded.push_back(tile);
408 }
409 }
410 encoded.push_back(0xFF);
411 encoded.push_back(0xFF);
412
413 // Find the end of existing collision data by scanning all room pointers
414 // to determine the highest used offset.
415 const size_t safe_end =
416 std::min(static_cast<size_t>(data.size()),
417 static_cast<size_t>(kCustomCollisionDataSoftEnd));
418 uint32_t max_used_pc = static_cast<uint32_t>(kCustomCollisionDataPosition);
419 std::vector<CollisionBlob> blobs;
420 blobs.reserve(kNumberOfRooms);
421 for (int r = 0; r < kNumberOfRooms; ++r) {
422 int ptr_offset = kCustomCollisionRoomPointers + (r * 3);
423 if (ptr_offset + 2 >= static_cast<int>(data.size()))
424 continue;
425
426 uint32_t snes_ptr = data[ptr_offset] | (data[ptr_offset + 1] << 8) |
427 (data[ptr_offset + 2] << 16);
428 if (snes_ptr == 0)
429 continue;
430
431 uint32_t pc = SnesToPc(snes_ptr);
432 if (pc < static_cast<uint32_t>(kCustomCollisionDataPosition)) {
433 return absl::FailedPreconditionError(
434 absl::StrFormat("Custom collision pointer for room 0x%02X points "
435 "before data region (pc=0x%06X)",
436 r, pc));
437 }
438 if (pc >= static_cast<uint32_t>(kCustomCollisionDataSoftEnd)) {
439 return absl::FailedPreconditionError(
440 absl::StrFormat("Custom collision pointer for room 0x%02X overlaps "
441 "WaterFill reserved region (pc=0x%06X)",
442 r, pc));
443 }
444 if (pc >= data.size()) {
445 return absl::OutOfRangeError("Custom collision pointer out of ROM range");
446 }
447 ASSIGN_OR_RETURN(const uint32_t end_pc,
448 FindCollisionBlobEnd(data, pc, safe_end, r));
449 blobs.push_back(CollisionBlob{r, pc, end_pc});
450 if (end_pc > max_used_pc) {
451 max_used_pc = end_pc;
452 }
453 }
454
455 // Reuse the selected room's current blob only when its entire physical span
456 // is uniquely owned and the replacement fits. Aliased or overlapping blobs
457 // remain copy-on-write so editing one room cannot mutate another room.
458 uint32_t write_pos = max_used_pc;
459 const auto target = std::find_if(
460 blobs.begin(), blobs.end(),
461 [room_id](const CollisionBlob& blob) { return blob.room_id == room_id; });
462 if (target != blobs.end() && encoded.size() <= target->end - target->start) {
463 const bool overlaps_other = std::any_of(
464 blobs.begin(), blobs.end(), [&](const CollisionBlob& other) {
465 return other.room_id != room_id && BlobsOverlap(*target, other);
466 });
467 if (!overlaps_other) {
468 write_pos = target->start;
469 }
470 }
471
472 // Append when there is no safe reusable span, then check available space.
473 if (write_pos + encoded.size() > kCustomCollisionDataSoftEnd) {
474 return absl::ResourceExhaustedError(absl::StrFormat(
475 "Not enough collision data space. Need %d bytes at 0x%06X, "
476 "region ends at 0x%06X",
477 encoded.size(), write_pos, kCustomCollisionDataSoftEnd));
478 }
479
480 if (write_pos + encoded.size() > data.size()) {
481 return absl::OutOfRangeError(
482 absl::StrFormat("ROM too small for custom collision write (need "
483 "end=0x%06X, size=0x%06X)",
484 write_pos + encoded.size(), data.size()));
485 }
487 rom->WriteVector(static_cast<int>(write_pos), std::move(encoded)));
488
489 // Update pointer table: 3-byte SNES address
490 uint32_t snes_addr = PcToSnes(write_pos);
491 int ptr_offset = kCustomCollisionRoomPointers + (room_id * 3);
492 RETURN_IF_ERROR(rom->WriteByte(ptr_offset, snes_addr & 0xFF));
493 RETURN_IF_ERROR(rom->WriteByte(ptr_offset + 1, (snes_addr >> 8) & 0xFF));
494 RETURN_IF_ERROR(rom->WriteByte(ptr_offset + 2, (snes_addr >> 16) & 0xFF));
495
496 return absl::OkStatus();
497}
498
500 // Find bounding box of non-zero tiles to avoid printing the entire 64x64.
501 int min_x = kGridSize, max_x = 0, min_y = kGridSize, max_y = 0;
502 for (int y = 0; y < kGridSize; ++y) {
503 for (int x = 0; x < kGridSize; ++x) {
504 if (map.tiles[y * kGridSize + x] != 0) {
505 min_x = std::min(min_x, x);
506 max_x = std::max(max_x, x);
507 min_y = std::min(min_y, y);
508 max_y = std::max(max_y, y);
509 }
510 }
511 }
512
513 if (min_x > max_x)
514 return "(empty)\n";
515
516 // Add 1-tile padding
517 min_x = std::max(0, min_x - 1);
518 min_y = std::max(0, min_y - 1);
519 max_x = std::min(kGridSize - 1, max_x + 1);
520 max_y = std::min(kGridSize - 1, max_y + 1);
521
522 std::stringstream ss;
523 // Column header
524 ss << " ";
525 for (int x = min_x; x <= max_x; ++x) {
526 ss << absl::StrFormat("%X", x % 16);
527 }
528 ss << "\n";
529
530 for (int y = min_y; y <= max_y; ++y) {
531 ss << absl::StrFormat("%02X: ", y);
532 for (int x = min_x; x <= max_x; ++x) {
533 uint8_t tile = map.tiles[y * kGridSize + x];
534 ss << TileToChar(tile);
535 }
536 ss << "\n";
537 }
538
539 return ss.str();
540}
541
542} // namespace zelda3
543} // 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:405
void EnsureObjectsLoaded()
Definition room.cc:948
int id() const
Definition room.h:948
#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
std::vector< std::pair< int, int > > switch_promotions
std::vector< std::tuple< int, int, TrackTileType > > stop_overrides