6#include "absl/strings/match.h"
7#include "absl/strings/str_cat.h"
8#include "absl/strings/str_split.h"
10#include "nlohmann/json.hpp"
20 std::ostringstream oss;
21 oss <<
"Map " <<
map_id <<
" @ (" <<
x <<
"," <<
y <<
"): "
27 std::ostringstream json;
29 json <<
" \"id\": \"" <<
id <<
"\",\n";
30 json <<
" \"prompt\": \"" <<
prompt <<
"\",\n";
31 json <<
" \"ai_service\": \"" <<
ai_service <<
"\",\n";
32 json <<
" \"reasoning\": \"" <<
reasoning <<
"\",\n";
33 json <<
" \"status\": ";
37 json <<
"\"pending\"";
40 json <<
"\"accepted\"";
43 json <<
"\"rejected\"";
46 json <<
"\"applied\"";
51 json <<
" \"changes\": [\n";
52 for (
size_t i = 0; i <
changes.size(); ++i) {
53 const auto& change =
changes[i];
55 json <<
" \"map_id\": " << change.map_id <<
",\n";
56 json <<
" \"x\": " << change.x <<
",\n";
57 json <<
" \"y\": " << change.y <<
",\n";
58 json <<
" \"old_tile\": \"0x" << std::hex << change.old_tile
60 json <<
" \"new_tile\": \"0x" << std::hex << change.new_tile <<
"\"\n";
76 if (!json.contains(field)) {
77 return absl::InvalidArgumentError(
78 absl::StrCat(
"Missing field '", field,
"' in proposal change"));
81 if (json[field].is_number_integer()) {
82 int value = json[field].get<
int>();
83 if (value < 0 || value > 0xFFFF) {
84 return absl::InvalidArgumentError(
85 absl::StrCat(
"Tile value for '", field,
"' out of range: ", value));
87 return static_cast<uint16_t
>(value);
90 if (json[field].is_string()) {
91 std::string value = json[field].get<std::string>();
93 return absl::InvalidArgumentError(
94 absl::StrCat(
"Tile value for '", field,
"' is empty"));
98 if (absl::StartsWith(value,
"0x") || absl::StartsWith(value,
"0X")) {
99 if (value.size() <= 2) {
100 return absl::InvalidArgumentError(
101 absl::StrCat(
"Invalid hex tile value for '", field,
102 "': ", json[field].get<std::string>()));
104 value = value.substr(2);
105 unsigned int parsed = 0;
106 if (!ParseHexString(value, &parsed) || parsed > 0xFFFF) {
107 return absl::InvalidArgumentError(
108 absl::StrCat(
"Invalid hex tile value for '", field,
109 "': ", json[field].get<std::string>()));
111 return static_cast<uint16_t
>(parsed);
114 unsigned int parsed = 0;
115 if (!absl::SimpleAtoi(value, &parsed) || parsed > 0xFFFF) {
116 return absl::InvalidArgumentError(
117 absl::StrCat(
"Invalid tile value for '", field,
118 "': ", json[field].get<std::string>()));
120 return static_cast<uint16_t
>(parsed);
123 return absl::InvalidArgumentError(
124 absl::StrCat(
"Unsupported JSON type for tile field '", field,
"'"));
128 if (absl::StartsWith(status_text,
"accept")) {
131 if (absl::StartsWith(status_text,
"reject")) {
134 if (absl::StartsWith(status_text,
"apply")) {
143 const std::string& json_text) {
146 json = nlohmann::json::parse(json_text);
147 }
catch (
const nlohmann::json::parse_error& error) {
148 return absl::InvalidArgumentError(
149 absl::StrCat(
"Failed to parse proposal JSON: ", error.what()));
154 if (!json.contains(
"id") || !json[
"id"].is_string()) {
155 return absl::InvalidArgumentError(
156 "Proposal JSON must include string field 'id'");
158 proposal.
id = json[
"id"].get<std::string>();
160 if (!json.contains(
"prompt") || !json[
"prompt"].is_string()) {
161 return absl::InvalidArgumentError(
162 "Proposal JSON must include string field 'prompt'");
164 proposal.
prompt = json[
"prompt"].get<std::string>();
166 if (json.contains(
"ai_service") && json[
"ai_service"].is_string()) {
167 proposal.
ai_service = json[
"ai_service"].get<std::string>();
170 if (json.contains(
"reasoning") && json[
"reasoning"].is_string()) {
171 proposal.
reasoning = json[
"reasoning"].get<std::string>();
174 if (json.contains(
"status")) {
175 if (!json[
"status"].is_string()) {
176 return absl::InvalidArgumentError(
177 "Proposal 'status' must be a string value");
179 proposal.
status = ParseStatus(json[
"status"].get<std::string>());
184 if (json.contains(
"changes")) {
185 if (!json[
"changes"].is_array()) {
186 return absl::InvalidArgumentError(
187 "Proposal 'changes' field must be an array");
190 for (
const auto& change_json : json[
"changes"]) {
191 if (!change_json.is_object()) {
192 return absl::InvalidArgumentError(
193 "Each change entry must be a JSON object");
197 if (!change_json.contains(
"map_id") ||
198 !change_json[
"map_id"].is_number_integer()) {
199 return absl::InvalidArgumentError(
200 "Tile change missing integer field 'map_id'");
202 change.
map_id = change_json[
"map_id"].get<
int>();
204 if (!change_json.contains(
"x") || !change_json[
"x"].is_number_integer()) {
205 return absl::InvalidArgumentError(
206 "Tile change missing integer field 'x'");
208 change.
x = change_json[
"x"].get<
int>();
210 if (!change_json.contains(
"y") || !change_json[
"y"].is_number_integer()) {
211 return absl::InvalidArgumentError(
212 "Tile change missing integer field 'y'");
214 change.
y = change_json[
"y"].get<
int>();
217 ParseTileValue(change_json,
"old_tile"));
219 ParseTileValue(change_json,
"new_tile"));
221 proposal.
changes.push_back(change);
225 if (proposal.
changes.empty()) {
226 return absl::InvalidArgumentError(
227 "Proposal JSON did not include any tile16 changes");
230 proposal.
created_at = std::chrono::system_clock::now();
231 if (json.contains(
"created_at_ms") && json[
"created_at_ms"].is_number()) {
232 auto millis = json[
"created_at_ms"].get<int64_t>();
233 proposal.
created_at = std::chrono::system_clock::time_point(
234 std::chrono::milliseconds(millis));
242 auto now = std::chrono::system_clock::now();
243 auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
244 now.time_since_epoch())
247 std::ostringstream oss;
248 oss <<
"proposal_" << ms;
253 const std::string& command,
Rom* rom) {
255 std::vector<std::string> parts = absl::StrSplit(command,
' ');
257 if (parts.size() < 10) {
258 return absl::InvalidArgumentError(
259 absl::StrCat(
"Invalid command format: ", command));
262 if (parts[0] !=
"overworld" || parts[1] !=
"set-tile") {
263 return absl::InvalidArgumentError(
264 absl::StrCat(
"Not a set-tile command: ", command));
270 for (
size_t i = 2; i < parts.size(); i += 2) {
271 if (i + 1 >= parts.size())
274 const std::string& flag = parts[i];
275 const std::string& value = parts[i + 1];
277 if (flag ==
"--map") {
278 change.
map_id = std::stoi(value);
279 }
else if (flag ==
"--x") {
280 change.
x = std::stoi(value);
281 }
else if (flag ==
"--y") {
282 change.
y = std::stoi(value);
283 }
else if (flag ==
"--tile") {
285 change.
new_tile =
static_cast<uint16_t
>(std::stoi(value,
nullptr, 16));
292 auto status = overworld.
Load(rom);
298 if (change.
map_id < 0x40) {
300 }
else if (change.
map_id < 0x80) {
314absl::StatusOr<std::vector<Tile16Change>>
319 std::vector<std::string> parts = absl::StrSplit(command,
' ');
321 if (parts.size() < 12) {
322 return absl::InvalidArgumentError(
323 absl::StrCat(
"Invalid set-area command format: ", command));
326 if (parts[0] !=
"overworld" || parts[1] !=
"set-area") {
327 return absl::InvalidArgumentError(
328 absl::StrCat(
"Not a set-area command: ", command));
331 int map_id = 0, x = 0, y = 0, width = 1, height = 1;
332 uint16_t new_tile = 0;
335 for (
size_t i = 2; i < parts.size(); i += 2) {
336 if (i + 1 >= parts.size())
339 const std::string& flag = parts[i];
340 const std::string& value = parts[i + 1];
342 if (flag ==
"--map") {
343 map_id = std::stoi(value);
344 }
else if (flag ==
"--x") {
345 x = std::stoi(value);
346 }
else if (flag ==
"--y") {
347 y = std::stoi(value);
348 }
else if (flag ==
"--width") {
349 width = std::stoi(value);
350 }
else if (flag ==
"--height") {
351 height = std::stoi(value);
352 }
else if (flag ==
"--tile") {
353 new_tile =
static_cast<uint16_t
>(std::stoi(value,
nullptr, 16));
358 std::vector<Tile16Change> changes;
361 auto status = overworld.
Load(rom);
369 }
else if (map_id < 0x80) {
376 for (
int dy = 0; dy < height; ++dy) {
377 for (
int dx = 0; dx < width; ++dx) {
384 changes.push_back(change);
389 for (
int dy = 0; dy < height; ++dy) {
390 for (
int dx = 0; dx < width; ++dx) {
397 changes.push_back(change);
405absl::StatusOr<std::vector<Tile16Change>>
411 std::vector<std::string> parts = absl::StrSplit(command,
' ');
413 if (parts.size() < 8) {
414 return absl::InvalidArgumentError(
415 absl::StrCat(
"Invalid replace-tile command format: ", command));
418 if (parts[0] !=
"overworld" || parts[1] !=
"replace-tile") {
419 return absl::InvalidArgumentError(
420 absl::StrCat(
"Not a replace-tile command: ", command));
424 uint16_t old_tile = 0, new_tile = 0;
425 int x_min = 0, y_min = 0, x_max = 31, y_max = 31;
428 for (
size_t i = 2; i < parts.size(); i += 2) {
429 if (i + 1 >= parts.size())
432 const std::string& flag = parts[i];
433 const std::string& value = parts[i + 1];
435 if (flag ==
"--map") {
436 map_id = std::stoi(value);
437 }
else if (flag ==
"--old-tile") {
438 old_tile =
static_cast<uint16_t
>(std::stoi(value,
nullptr, 16));
439 }
else if (flag ==
"--new-tile") {
440 new_tile =
static_cast<uint16_t
>(std::stoi(value,
nullptr, 16));
441 }
else if (flag ==
"--x-min") {
442 x_min = std::stoi(value);
443 }
else if (flag ==
"--y-min") {
444 y_min = std::stoi(value);
445 }
else if (flag ==
"--x-max") {
446 x_max = std::stoi(value);
447 }
else if (flag ==
"--y-max") {
448 y_max = std::stoi(value);
453 return absl::FailedPreconditionError(
454 "ROM must be loaded to scan for tiles to replace");
458 auto status = overworld.
Load(rom);
466 }
else if (map_id < 0x80) {
473 std::vector<Tile16Change> changes;
474 for (
int y = y_min; y <= y_max; ++y) {
475 for (
int x = x_min; x <= x_max; ++x) {
476 uint16_t current_tile = overworld.
GetTile(x, y);
477 if (current_tile == old_tile) {
484 changes.push_back(change);
489 if (changes.empty()) {
490 std::ostringstream oss;
491 oss <<
"0x" << std::hex << old_tile;
492 return absl::NotFoundError(absl::StrCat(
"No tiles matching ", oss.str(),
493 " found in specified area"));
500 const std::string& prompt,
const std::vector<std::string>& commands,
501 const std::string& ai_service,
Rom* rom) {
506 proposal.
created_at = std::chrono::system_clock::now();
510 for (
const auto& command : commands) {
512 if (command.empty() || command[0] ==
'#') {
517 if (absl::StrContains(command,
"overworld set-tile")) {
519 if (change_or.ok()) {
520 proposal.
changes.push_back(change_or.value());
522 return change_or.status();
524 }
else if (absl::StrContains(command,
"overworld set-area")) {
526 if (changes_or.ok()) {
528 changes_or.value().begin(),
529 changes_or.value().end());
531 return changes_or.status();
533 }
else if (absl::StrContains(command,
"overworld replace-tile")) {
535 if (changes_or.ok()) {
537 changes_or.value().begin(),
538 changes_or.value().end());
540 return changes_or.status();
545 if (proposal.
changes.empty()) {
546 return absl::InvalidArgumentError(
547 "No valid tile16 changes found in commands");
551 " tile16 changes from prompt");
559 return absl::FailedPreconditionError(
"ROM not loaded");
563 auto status = overworld.
Load(rom);
569 for (
const auto& change : proposal.
changes) {
571 if (change.map_id < 0x40) {
573 }
else if (change.map_id < 0x80) {
580 overworld.
SetTile(change.x, change.y, change.new_tile);
586 return absl::OkStatus();
591 if (!before_rom || !before_rom->
is_loaded()) {
592 return absl::FailedPreconditionError(
"Before ROM not loaded");
595 if (!after_rom || !after_rom->
is_loaded()) {
596 return absl::FailedPreconditionError(
"After ROM not loaded");
599 if (proposal.
changes.empty()) {
600 return absl::InvalidArgumentError(
"No changes to visualize");
604 int min_x = INT_MAX, min_y = INT_MAX;
605 int max_x = INT_MIN, max_y = INT_MIN;
606 int map_id = proposal.
changes[0].map_id;
608 for (
const auto& change : proposal.
changes) {
609 if (change.x < min_x)
611 if (change.y < min_y)
613 if (change.x > max_x)
615 if (change.y > max_y)
621 min_x = std::max(0, min_x - padding);
622 min_y = std::max(0, min_y - padding);
623 max_x = std::min(31, max_x + padding);
624 max_y = std::min(31, max_y + padding);
626 int width = (max_x - min_x + 1) * 16;
627 int height = (max_y - min_y + 1) * 16;
630 int diff_width = width * 2 + 8;
631 int diff_height = height;
633 std::vector<uint8_t> diff_data(diff_width * diff_height, 0x00);
634 gfx::Bitmap diff_bitmap(diff_width, diff_height, 8, diff_data);
640 auto before_status = before_overworld.
Load(before_rom);
641 if (!before_status.ok()) {
642 return before_status;
645 auto after_status = after_overworld.
Load(after_rom);
646 if (!after_status.ok()) {
654 }
else if (map_id < 0x80) {
671 for (
int y = min_y; y <= max_y; ++y) {
672 for (
int x = min_x; x <= max_x; ++x) {
673 uint16_t before_tile = before_overworld.
GetTile(x, y);
674 uint16_t after_tile = after_overworld.
GetTile(x, y);
676 bool is_changed = (before_tile != after_tile);
680 int pixel_x = (x - min_x) * 16;
681 int pixel_y = (y - min_y) * 16;
682 for (
int py = 0; py < 16; ++py) {
683 for (
int px = 0; px < 16; ++px) {
684 diff_bitmap.
SetPixel(pixel_x + px, pixel_y + py, color);
689 int right_offset = width + 8;
690 for (
int py = 0; py < 16; ++py) {
691 for (
int px = 0; px < 16; ++px) {
692 diff_bitmap.
SetPixel(right_offset + pixel_x + px, pixel_y + py,
700 for (
int y = 0; y < diff_height; ++y) {
701 for (
int x = 0; x < 8; ++x) {
702 diff_bitmap.
SetPixel(width + x, y, separator_color);
711 std::ofstream file(path);
712 if (!file.is_open()) {
713 return absl::InvalidArgumentError(
714 absl::StrCat(
"Failed to open file for writing: ", path));
717 file << proposal.
ToJson();
720 return absl::OkStatus();
724 const std::string& path) {
725 std::ifstream file(path);
726 if (!file.is_open()) {
727 return absl::InvalidArgumentError(
728 absl::StrCat(
"Failed to open file for reading: ", path));
731 std::stringstream buffer;
732 buffer << file.rdbuf();
The Rom class is used to load, save, and modify Rom data. This is a generic SNES ROM container and do...
std::string GenerateProposalId() const
Generate a unique proposal ID.
absl::StatusOr< Tile16Proposal > GenerateFromCommands(const std::string &prompt, const std::vector< std::string > &commands, const std::string &ai_service, Rom *rom)
Generate a tile16 proposal from an AI-generated command list.
absl::StatusOr< Tile16Proposal > LoadProposal(const std::string &path)
Load a proposal from a JSON file.
absl::StatusOr< std::vector< Tile16Change > > ParseReplaceTileCommand(const std::string &command, Rom *rom)
Parse a "overworld replace-tile" command into multiple Tile16Changes.
absl::StatusOr< gfx::Bitmap > GenerateDiff(const Tile16Proposal &proposal, Rom *before_rom, Rom *after_rom)
Generate a visual diff bitmap for a proposal.
absl::Status ApplyProposal(const Tile16Proposal &proposal, Rom *rom)
Apply a proposal to a ROM (typically a sandbox).
absl::Status SaveProposal(const Tile16Proposal &proposal, const std::string &path)
Save a proposal to a JSON file for later review.
absl::StatusOr< std::vector< Tile16Change > > ParseSetAreaCommand(const std::string &command, Rom *rom)
Parse a "overworld set-area" command into multiple Tile16Changes.
absl::StatusOr< Tile16Change > ParseSetTileCommand(const std::string &command, Rom *rom)
Parse a single "overworld set-tile" command into a Tile16Change.
Represents a bitmap image optimized for SNES ROM hacking.
void SetPixel(int x, int y, const SnesColor &color)
Set a pixel at the given x,y coordinates with SNES color.
Represents the full Overworld data, light and dark world.
void set_current_world(int world)
absl::Status Load(Rom *rom)
Load all overworld data from ROM.
uint16_t GetTile(int x, int y) const
void SetTile(int x, int y, uint16_t tile_id)
#define ASSIGN_OR_RETURN(type_variable_name, expression)
absl::StatusOr< uint16_t > ParseTileValue(const nlohmann::json &json, const char *field)
bool ParseHexString(absl::string_view str, int *out)
Represents a single tile16 change in a proposal.
std::string ToString() const
Represents a proposal for tile16 edits on the overworld.
std::chrono::system_clock::time_point created_at
std::vector< Tile16Change > changes
std::string ToJson() const
static absl::StatusOr< Tile16Proposal > FromJson(const std::string &json)