Skip to content

Commit

Permalink
Serialize entities to JSON
Browse files Browse the repository at this point in the history
- Add JSON library that can marshal Noita entities and components
- Add Noita API wrapper that exposes entities and components as objects
- Change how the entities file is written, to support lightweight and crash proof appending of JSON data

#9
  • Loading branch information
Dadido3 committed Jul 17, 2022
1 parent 8612721 commit 833ab41
Show file tree
Hide file tree
Showing 3 changed files with 715 additions and 18 deletions.
53 changes: 35 additions & 18 deletions files/capture.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT

---@type NoitaAPI
local noitaAPI = dofile_once("mods/noita-mapcap/files/noita-api.lua")

---@type JSONLib
local jsonSerialize = dofile_once("mods/noita-mapcap/files/json-serialize.lua")

CAPTURE_PIXEL_SIZE = 1 -- Screen to virtual pixel ratio.
CAPTURE_GRID_SIZE = 512 -- in virtual (world) pixels. There will always be exactly 4 images overlapping if the virtual resolution is 1024x1024.
CAPTURE_FORCE_HP = 4 -- * 25HP
Expand Down Expand Up @@ -84,18 +90,31 @@ local function captureScreenshot(x, y, rx, ry, entityFile)

-- Capture entities right after capturing the screenshot.
if entityFile then
local radius = math.sqrt(virtualHalfWidth^2 + virtualHalfHeight^2) + 1
local entities = EntityGetInRadius(x, y, radius)
for _, entityID in ipairs(entities) do
-- Make sure to only export entities when they are encountered the first time.
if not EntityHasTag(entityID, "MapCaptured") then
local x, y, rotation, scaleX, scaleY = EntityGetTransform(entityID)
local entityName = EntityGetName(entityID)
local entityTags = EntityGetTags(entityID)
entityFile:write(string.format("%d, %s, %f, %f, %f, %f, %f, %q\n", entityID, entityName, x, y, rotation, scaleX, scaleY, entityTags))
-- TODO: Correctly escape CSV data
EntityAddTag(entityID, "MapCaptured") -- Prevent recapturing.
local ok, err = pcall(function()
local radius = math.sqrt(virtualHalfWidth^2 + virtualHalfHeight^2) + 1
local entities = noitaAPI.Entity.GetInRadius(x, y, radius)
for _, entity in ipairs(entities) do
-- Get to the root entity, as we are exporting entire entity trees.
local rootEntity = entity:GetRootEntity()
-- Make sure to only export entities when they are encountered the first time.
if not rootEntity:HasTag("MapCaptured") then
-- Some hacky way to generate valid JSON that doesn't break when the game crashes.
-- Well, as long as it does not crash between write and flush.
if entityFile:seek("end") == 0 then
-- First line.
entityFile:write("[\n\t", jsonSerialize.Marshal(rootEntity), "\n", "]")
else
-- Following lines.
entityFile:seek("end", -2) -- Seek a few bytes back, so we can overwrite some stuff.
entityFile:write(",\n\t", jsonSerialize.Marshal(rootEntity), "\n", "]")
end

rootEntity:AddTag("MapCaptured") -- Prevent recapturing.
end
end
end)
if not ok then
print("Entity export error:", err)
end
entityFile:flush() -- Ensure everything is written to disk before noita decides to crash.
end
Expand All @@ -105,16 +124,14 @@ local function captureScreenshot(x, y, rx, ry, entityFile)
end

local function createOrOpenEntityCaptureFile()
-- Make sure the file exists.
local file = io.open("mods/noita-mapcap/output/entities.csv", "a")
if file ~= nil then file:close() end

-- Create or reopen entities CSV file.
local file = io.open("mods/noita-mapcap/output/entities.csv", "a+")
file = io.open("mods/noita-mapcap/output/entities.csv", "r+b") -- Open for reading (r) and writing (+) in binary mode. r+b will not truncate the file to 0.
if file == nil then return nil end

if file:seek("end") == 0 then
-- Empty file: Create header.
file:write("entityID, entityName, x, y, rotation, scaleX, scaleY, tags\n")
file:flush()
end

return file
end

Expand Down
207 changes: 207 additions & 0 deletions files/json-serialize.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
-- Copyright (c) 2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT

-- Simple library to marshal JSON values.

---@type NoitaAPI
local noitaAPI = dofile_once("mods/noita-mapcap/files/noita-api.lua")

---@class JSONLib
local lib = {}

---Maps single characters to escaped strings.
---
---Copyright (c) 2020 rxi
---@see [github.com/rxi/json.lua](https://github.com/rxi/json.lua/blob/master/json.lua)
local escapeCharacters = {
["\\"] = "\\",
["\""] = "\"",
["\b"] = "b",
["\f"] = "f",
["\n"] = "n",
["\r"] = "r",
["\t"] = "t",
}

---escapeRune returns the escaped string for a given rune.
---
---Copyright (c) 2020 rxi
---@see [github.com/rxi/json.lua](https://github.com/rxi/json.lua/blob/master/json.lua)
---@param rune string
---@return string
local function escapeCharacter(rune)
return "\\" .. (escapeCharacters[rune] or string.format("u%04x", rune:byte()))
end

---escapeString returns the escaped version of the given string.
---
---Copyright (c) 2020 rxi
---@see [github.com/rxi/json.lua](https://github.com/rxi/json.lua/blob/master/json.lua)
---@param str string
---@return string
local function escapeString(str)
local result, count = str:gsub('[%z\1-\31\\"]', escapeCharacter)
return result
end

---MarshalString returns the JSON representation of a string value.
---@param val string
---@return string
function lib.MarshalString(val)
return string.format("%q", escapeString(val)) -- TODO: Escape strings correctly.
end

---MarshalNumber returns the JSON representation of a number value.
---@param val number
---@return string
function lib.MarshalNumber(val)
-- TODO: Marshal NaN, +Inf, -Inf, ... correctly

return tostring(val)
end

---MarshalBoolean returns the JSON representation of a boolean value.
---@param val number
---@return string
function lib.MarshalBoolean(val)
return tostring(val)
end

---MarshalObject returns the JSON representation of a table object.
---
---This only works with string keys. Number keys will be converted into strings.
---@param val table<string,any>
---@return string
function lib.MarshalObject(val)
local result = "{"

for k, v in pairs(val) do
result = result .. lib.MarshalString(k) .. ": " .. lib.Marshal(v)
-- Append character depending on whether this is the last element or not.
if next(val, k) == nil then
result = result .. "}"
else
result = result .. ", "
end
end

return result
end

---MarshalArray returns the JSON representation of an array object.
---
---@param val table<number,any>
---@param customMarshalFunction function|nil -- Custom function for marshalling the array values.
---@return string
function lib.MarshalArray(val, customMarshalFunction)
local result = "["

-- TODO: Check if the type of all array entries is the same.

local length = #val
for i, v in ipairs(val) do
if customMarshalFunction then
result = result .. customMarshalFunction(v)
else
result = result .. lib.Marshal(v)
end
-- Append character depending on whether this is the last element or not.
if i == length then
result = result .. "]"
else
result = result .. ", "
end
end

return result
end

---MarshalNoitaComponent returns the JSON representation of the given Noita component.
---@param component NoitaComponent
---@return string
function lib.MarshalNoitaComponent(component)
local resultObject = {
typeName = component:GetTypeName(),
members = component:GetMembers(),
--objectMembers = component:ObjectGetMembers
}

return lib.Marshal(resultObject)
end

---MarshalNoitaEntity returns the JSON representation of the given Noita entity.
---@param entity NoitaEntity
---@return string
function lib.MarshalNoitaEntity(entity)
local result = {
name = entity:GetName(),
filename = entity:GetFilename(),
tags = entity:GetTags(),
children = entity:GetAllChildren(),
components = entity:GetAllComponents(),
transform = {},
}

result.transform.x, result.transform.y, result.transform.rotation, result.transform.scaleX, result.transform.scaleY = entity:GetTransform()

return lib.Marshal(result)
end

---Marshal marshals any value into JSON representation.
---@param val any
---@return string
function lib.Marshal(val)
local t = type(val)

if t == "nil" then
return "null"
elseif t == "number" then
return lib.MarshalNumber(val)
elseif t == "string" then
return lib.MarshalString(val)
elseif t == "boolean" then
return lib.MarshalBoolean(val)
elseif t == "table" then
-- Check if object is instance of class...
if getmetatable(val) == noitaAPI.MetaTables.Component then
return lib.MarshalNoitaComponent(val)
elseif getmetatable(val) == noitaAPI.MetaTables.Entity then
return lib.MarshalNoitaEntity(val)
end

-- If not, fall back to array or object handling.
local commonKeyType, commonValueType
for k, v in pairs(val) do
local keyType, valueType = type(k), type(v)
commonKeyType = commonKeyType or keyType
if commonKeyType ~= keyType then
-- Different types detected, abort.
commonKeyType = "mixed"
break
end
commonValueType = commonValueType or valueType
if commonValueType ~= valueType then
-- Different types detected, abort.
commonValueType = "mixed"
break
end
end

-- Decide based on common types.
if commonKeyType == "number" and commonValueType ~= "mixed" then
return lib.MarshalArray(val) -- This will falsely detect sparse integer key maps as arrays. But meh.
elseif commonKeyType == "string" then
return lib.MarshalObject(val) -- This will not detect if there are number keys, which would work with MarshalObject.
elseif commonKeyType == nil and commonValueType == nil then
return "null" -- Fallback in case of empty table. There is no other way than using null, as we don't have type information without table elements.
end

error(string.format("unsupported table type. CommonKeyType = %s. CommonValueType = %s. MetaTable = %s", commonKeyType or "nil", commonValueType or "nil", getmetatable(val) or "nil"))
end

error(string.format("unsupported type %q", t))
end

return lib
Loading

0 comments on commit 833ab41

Please sign in to comment.