mirror of
https://github.com/Dadido3/noita-mapcap.git
synced 2025-04-17 23:33:16 +00:00
Compare commits
128 Commits
Author | SHA1 | Date | |
---|---|---|---|
f7ac3c4009 | |||
|
09d8bcfe09 | ||
b76233caa4 | |||
b6224e657f | |||
|
3e5950810a | ||
d3edf29a80 | |||
|
3fd0d970b7 | ||
|
627dc545d4 | ||
203a0ea159 | |||
0065afe413 | |||
9c6e57b340 | |||
44ed26952c | |||
|
1aa8b02882 | ||
|
e125df80b2 | ||
1936dc100c | |||
1d83b0dfa0 | |||
|
442c8f7f43 | ||
dcfe240bfc | |||
|
42f3af0655 | ||
4e79c08e6a | |||
aee72fd3c6 | |||
04eeca26e2 | |||
ae6bcd9743 | |||
920288614b | |||
c0fd7aab56 | |||
20d7c330a6 | |||
05e6b18745 | |||
|
f109c66152 | ||
|
8b2fe81f42 | ||
|
0758ec7a62 | ||
|
a9141679f4 | ||
690a2c55ab | |||
751877b472 | |||
b4dca53fc8 | |||
9cfb01187c | |||
dddaad938f | |||
41271d5321 | |||
b1a10870c1 | |||
47d570014d | |||
421f897be7 | |||
9c728e0ae2 | |||
d9d8c9cd78 | |||
15e2b88ed5 | |||
9e51538f3f | |||
24a1615706 | |||
45df692b96 | |||
93a1283188 | |||
ace1ab145a | |||
4f3f5c594d | |||
f22ef05411 | |||
d82fda528a | |||
860b724bd0 | |||
8057b14d8e | |||
0e431c64d3 | |||
f2b1aba994 | |||
1a735c06bd | |||
69f5d1ccb3 | |||
44605b9633 | |||
d5cd88a30e | |||
4de83e3dcd | |||
d774cf373d | |||
9da52a3f70 | |||
e83aa6803a | |||
f7426f3ed5 | |||
478e1284fb | |||
8bb8adf1ba | |||
355521b144 | |||
b76124b2e4 | |||
f0ee3e2399 | |||
b9fc890581 | |||
a96431361f | |||
88507af167 | |||
7a6915480b | |||
915da73845 | |||
6d028d4064 | |||
a0d5c13557 | |||
3016919348 | |||
cbdd925c30 | |||
a0168df91f | |||
182373d3cc | |||
0454e29e34 | |||
a70a5a4d1a | |||
f5a3bad396 | |||
905f629d2c | |||
|
b11b27d6c3 | ||
|
6ef2f7d1d3 | ||
8f729d3829 | |||
a6a0cc14e1 | |||
2b0f6a25f6 | |||
d69177cd3b | |||
f992748443 | |||
22b5c1827d | |||
b22b42a8d1 | |||
f1a3010d72 | |||
|
ad50faebc9 | ||
|
c72574c55d | ||
9494588e7b | |||
7a85f646cb | |||
d8dab5c318 | |||
|
b1971bb4be | ||
|
1652b278cb | ||
486f8e642d | |||
f964f5d769 | |||
28a768a130 | |||
959b198e46 | |||
18682ed441 | |||
28c07dfd25 | |||
fcfd8c88ff | |||
|
6761492ea8 | ||
615faac8e4 | |||
72f8e92412 | |||
b2ed6f65d5 | |||
c9d2a37903 | |||
65f7cb4e60 | |||
c3f841a4ff | |||
f5693b96f1 | |||
df6c27924b | |||
3a73e13fb7 | |||
7a4dbeddf1 | |||
7d250d6405 | |||
cd1428706e | |||
1e5249d436 | |||
0044075cbf | |||
9406b598f8 | |||
6f2be8486e | |||
baea6292f1 | |||
85144f4b8f | |||
99fd8ce94f |
13
.github/FUNDING.yml
vendored
Normal file
13
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: Dadido3 # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
6
.github/workflows/build-release.yml
vendored
6
.github/workflows/build-release.yml
vendored
@ -8,18 +8,17 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build and release
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [windows]
|
||||
goarch: ["amd64"]
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.18
|
||||
go-version: ^1.22
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
@ -32,6 +31,7 @@ jobs:
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOOS: ${{ matrix.goos }}
|
||||
CGO_ENABLED: 1
|
||||
|
||||
- name: Create distribution archive
|
||||
run: go run -v ./scripts/dist
|
||||
|
2
.github/workflows/build-test.yml
vendored
2
.github/workflows/build-test.yml
vendored
@ -12,7 +12,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.18
|
||||
go-version: ^1.22
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -105,5 +105,9 @@ $RECYCLE.BIN/
|
||||
|
||||
/output/
|
||||
/dist/
|
||||
/bin/stitch/output.png
|
||||
/bin/stitch/*.png
|
||||
/bin/stitch/*.dzi
|
||||
/bin/stitch/*_files/
|
||||
/files/magic-numbers/generated.xml
|
||||
|
||||
/bin/stitch/captures/*
|
86
.vscode/settings.json
vendored
86
.vscode/settings.json
vendored
@ -1,41 +1,121 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"aabb",
|
||||
"acidflow",
|
||||
"appdata",
|
||||
"autosetup",
|
||||
"backbuffer",
|
||||
"basicfont",
|
||||
"bytecode",
|
||||
"cheggaaa",
|
||||
"Dadido",
|
||||
"dofile",
|
||||
"dont",
|
||||
"Downscales",
|
||||
"downscaling",
|
||||
"DPMM",
|
||||
"executables",
|
||||
"framebuffer",
|
||||
"framebuffers",
|
||||
"Fullscreen",
|
||||
"goarch",
|
||||
"gridify",
|
||||
"hacky",
|
||||
"hilbertify",
|
||||
"Hitbox",
|
||||
"ipairs",
|
||||
"kbinani",
|
||||
"Lanczos",
|
||||
"lann",
|
||||
"ldflags",
|
||||
"libwebp",
|
||||
"linearize",
|
||||
"longleg",
|
||||
"lowram",
|
||||
"luanxml",
|
||||
"manifoldco",
|
||||
"mapcap",
|
||||
"Metamethods",
|
||||
"metaobject",
|
||||
"Metatable",
|
||||
"nfnt",
|
||||
"Niccoli",
|
||||
"noita",
|
||||
"prerender",
|
||||
"noitamap",
|
||||
"Nolla",
|
||||
"NXML",
|
||||
"openseadragon",
|
||||
"pixelated",
|
||||
"polymorphed",
|
||||
"promptui",
|
||||
"rasterizer",
|
||||
"Regen",
|
||||
"respawn",
|
||||
"runfast",
|
||||
"savegames",
|
||||
"schollz",
|
||||
"screenshake",
|
||||
"svenstaro",
|
||||
"tcnksm",
|
||||
"tdewolff",
|
||||
"unmodded",
|
||||
"unstitchable",
|
||||
"upscaled",
|
||||
"Vogel",
|
||||
"Voronoi",
|
||||
"webp",
|
||||
"wepb",
|
||||
"xmax",
|
||||
"xmin",
|
||||
"ymax",
|
||||
"ymin"
|
||||
"ymin",
|
||||
"Zatherz"
|
||||
],
|
||||
"Lua.runtime.version": "LuaJIT",
|
||||
"Lua.format.defaultConfig": {
|
||||
"max_line_length": "512"
|
||||
},
|
||||
"Lua.workspace.ignoreSubmodules": false
|
||||
"Lua.workspace.ignoreSubmodules": false,
|
||||
"cSpell.enabledLanguageIds": [
|
||||
"asciidoc",
|
||||
"c",
|
||||
"cpp",
|
||||
"csharp",
|
||||
"css",
|
||||
"elixir",
|
||||
"erlang",
|
||||
"git-commit",
|
||||
"go",
|
||||
"graphql",
|
||||
"handlebars",
|
||||
"haskell",
|
||||
"html",
|
||||
"jade",
|
||||
"java",
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"json",
|
||||
"jsonc",
|
||||
"jupyter",
|
||||
"latex",
|
||||
"less",
|
||||
"markdown",
|
||||
"php",
|
||||
"plaintext",
|
||||
"python",
|
||||
"pug",
|
||||
"restructuredtext",
|
||||
"rust",
|
||||
"scala",
|
||||
"scss",
|
||||
"scminput",
|
||||
"swift",
|
||||
"text",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"vue",
|
||||
"yaml",
|
||||
"yml",
|
||||
"lua"
|
||||
]
|
||||
}
|
17
AREAS.md
17
AREAS.md
@ -1,6 +1,10 @@
|
||||
# Capture areas
|
||||
|
||||
A list of available capture areas.
|
||||
Other game-modes or mods may use a different biome setup, and therefore the coordinates shown here are not valid for them.
|
||||
The values shown are for an unmodded `New Game` world.
|
||||
The noita-mapcap mod will always automatically determine the required coordinates so that it correctly captures the base layout or multiples of it.
|
||||
|
||||
Coordinates are in in-game "virtual" or "world" pixels.
|
||||
`Right` and `Bottom` coordinates are not included in the rectangle.
|
||||
|
||||
@ -64,3 +68,16 @@ Bottom = 41984
|
||||
The end result will have a size of `51200 x 73728 pixels ~= 3775 megapixels`.
|
||||
|
||||

|
||||
|
||||
## `3 Worlds`
|
||||
|
||||
This area consists of `Main world` plus a full left and right parallel world.
|
||||
|
||||
``` lua
|
||||
Left = -53760
|
||||
Top = -31744
|
||||
Right = 53760
|
||||
Bottom = 41984
|
||||
```
|
||||
|
||||
The end result will have a size of `107520 x 73728 pixels ~= 7927 megapixels`.
|
||||
|
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019-2022 David Vogel
|
||||
Copyright (c) 2019-2023 David Vogel
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
67
README.md
67
README.md
@ -1,11 +1,14 @@
|
||||
# Noita map capture addon [](https://travis-ci.com/Dadido3/noita-mapcap)
|
||||
# Noita map capture addon
|
||||
|
||||
A mod for Noita that can capture images of the world and stitch them into one large image.
|
||||
It works with the regular Noita and the dev build.
|
||||
It works with the regular Noita build and the dev build.
|
||||
|
||||

|
||||

|
||||
|
||||
A resulting image with nearly 3.8 gigapixels can be [seen here](https://easyzoom.com/image/223556) (Warning: Spoilers).
|
||||
Map captures created with this mod can be viewed on [map.runfast.stream] (may contain spoilers).
|
||||
|
||||
If you are interested in creating similar captures, or if you want to contribute your own captures to [map.runfast.stream], you can take a look at [github.com/acidflow-noita/noitamap].
|
||||
There you'll find detailed step-by-step instructions on how to quickly capture large parts of the Noita world with as little visual glitches and other issues as possible.
|
||||
|
||||
## System requirements
|
||||
|
||||
@ -45,7 +48,7 @@ All you need to do is follow the given instructions, like:
|
||||
>
|
||||
> You can always *right* click  to reset the above mentioned settings back to Noita's default.
|
||||
|
||||
After all issue have been resolved you are free to start capturing.
|
||||
After all issues have been resolved you are free to start capturing.
|
||||
|
||||
To the top left of the window are 3 buttons:
|
||||
|
||||
@ -53,18 +56,18 @@ To the top left of the window are 3 buttons:
|
||||
You can always restart a capture, and it will resume where it was stopped.
|
||||
|
||||
-  Reveals the output directory in your file browser.
|
||||
This will contain raw screenshots that later can be stitched.
|
||||
This will contain raw screenshots and other recorded data that later can be stitched.
|
||||
|
||||
-  Reveals the directory of the stitch tool in your file browser.
|
||||
-  Reveals the stitching tool directory in your file browser.
|
||||
|
||||
To stitch the final result, click  to open the directory of the stitching tool.
|
||||
Start `stitch.exe` and proceed with the default values.
|
||||
After a few minutes the file `output.png` will be created.
|
||||
|
||||
>  See [stitcher/README.md](bin/stitch/README.md) for more information about all stitcher parameters.
|
||||
|
||||
## Mod settings
|
||||
|
||||
|
||||
|
||||
>  Use *right* mouse button to reset any mod setting to their default.
|
||||
|
||||
- `Mode`: Defines what the mod captures, and how it captures it:
|
||||
@ -78,6 +81,10 @@ After a few minutes the file `output.png` will be created.
|
||||
- `Spiral`: Will capture the world in a spiral.
|
||||
The center starting point of the spiral can either be your current viewport, the world center or some custom coordinates.
|
||||
|
||||
- `Animation`: Will capture an image sequence.
|
||||
This will capture whatever you see frame by frame and stores it in the output folder by frame number.
|
||||
You can't stitch the resulting images, but instead you can use something like ffmpeg to render the sequence into a video file.
|
||||
|
||||
### Advanced mod settings
|
||||
|
||||
- `World seed`: If non empty, this will set the next new game to this seed.
|
||||
@ -116,7 +123,7 @@ After a few minutes the file `output.png` will be created.
|
||||
- Glow
|
||||
- Gamma correction
|
||||
|
||||
- `Disable shaders, GUI and AI`: Also disables all postprocessing, any in game UI and will freeze all mobs.
|
||||
- `Disable shaders, GUI and AI`: Also disables all postprocessing, any in-game UI and will freeze all mobs.
|
||||
Only works in dev build.
|
||||
|
||||
- `Disable entity logic`: Will modify all encountered entities:
|
||||
@ -129,12 +136,12 @@ After a few minutes the file `output.png` will be created.
|
||||
|
||||
### Example settings
|
||||
|
||||
Use this for capturing while you are playing the game.
|
||||
Use these settings if you want to capture your in-game action.
|
||||
The sliders are at their default values:
|
||||
|
||||

|
||||
|
||||
Use this to capture the [base layout](AREAS.md#base-layout) with the least amount of glitches and artifacts.
|
||||
Use these settings to capture the [base layout](AREAS.md#base-layout) with the least amount of glitches and artifacts.
|
||||
The sliders are at their default values:
|
||||
|
||||

|
||||
@ -145,8 +152,9 @@ The sliders are at their default values:
|
||||
|
||||
There is not a lot you can do about it:
|
||||
|
||||
- You can try to increase the usable address space of your `.../Noita/noita_dev.exe` or `.../Noita/noita.exe` with [Large Address Aware](https://www.techpowerup.com/forums/threads/large-address-aware.112556/) or a similar tool.
|
||||
This will help with any crashes that are related to out of memory exceptions.
|
||||
- ~~You can try to increase the usable address space of your `.../Noita/noita_dev.exe` or `.../Noita/noita.exe` with [Large Address Aware] or a similar tool.
|
||||
This will help with any crashes that are related to out of memory exceptions.~~
|
||||
`Large Address Aware` is already set in newer Noita builds.
|
||||
|
||||
- You can disable the replay recorder.
|
||||
|
||||
@ -158,7 +166,7 @@ These can't really be prevented.
|
||||
All you can do is to click `Ignore always`.
|
||||
|
||||
Alternatively you can run the same capture in the regular Noita (non dev build), which has these messages disabled.
|
||||
With the exception that you can't disable the pixel and rigid body simulations it works as good as in the dev build.
|
||||
With the exception that you can't disable the pixel and rigid body simulations, the mod works just as well as in the dev build.
|
||||
|
||||
### The mod messed up my game
|
||||
|
||||
@ -171,22 +179,45 @@ To reset any permanent settings that may have been set by the mod:
|
||||
2. Start a new game.
|
||||
3. *Right* click  and follow instructions.
|
||||
|
||||
>  If you have changed any resolutions in your game's `config.xml`, you may have to re-apply these changes.
|
||||
> This also applies if you use any mods that makes Noita work on ultra-wide screens.
|
||||
> For these mods to work again after a reset, you need to go through their installation steps again.
|
||||
|
||||
Alternatively, you can reset **all** game settings by deleting:
|
||||
|
||||
- `"%appdata%\..\LocalLow\Nolla_Games_Noita\save_shared\config.xml"` for the regular Noita.
|
||||
- `"...\Noita\save_shared\config.xml"` for the dev build.
|
||||
|
||||
## Additional information
|
||||
### The objects in the stitched image are blurry
|
||||
|
||||
The stitcher uses median blending to remove any single frame artifacts and to correct for not rendered chunks.
|
||||
This will cause fast moving objects to completely disappear, and slow moving objects to get blurry.
|
||||
|
||||
To disable median blending, use the stitcher with `Blend tile limit` set to 1.
|
||||
This will cause the stitcher to only use the newest image tile for every resulting pixel.
|
||||
|
||||
## Viewing and hosting captures
|
||||
|
||||
The resulting stitched images are quite big.
|
||||
You can read [this comment](https://github.com/Dadido3/noita-mapcap/issues/7#issuecomment-723591552) that addresses how you can view, convert or even self-host your images.
|
||||
|
||||
You can use [github.com/Dadido3/noita-mapcap-openseadragon] if you want to host a browser based viewer on your own web space.
|
||||
|
||||
If you want to make your captures available to a wider audience, you should check out the [github.com/acidflow-noita/noitamap] project, which aims to make maps of all game modes (including mods) available to the public.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
This mod uses the [LuaNXML](https://github.com/zatherz/luanxml) library by [Zatherz](https://github.com/zatherz).
|
||||
This mod uses the [LuaNXML] library by [Zatherz].
|
||||
|
||||
Thanks to [Daniel Niccoli](https://github.com/danielniccoli) for figuring out how to change some in game options by manipulating process memory.
|
||||
Thanks to [Daniel Niccoli](https://github.com/danielniccoli) for figuring out how to change some in-game options by manipulating process memory.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
[github.com/acidflow-noita/noitamap]: https://github.com/acidflow-noita/noitamap
|
||||
[github.com/Dadido3/noita-mapcap-openseadragon]: https://github.com/Dadido3/noita-mapcap-openseadragon
|
||||
[Large Address Aware]: https://www.techpowerup.com/forums/threads/large-address-aware.112556/
|
||||
[LuaNXML]: https://github.com/zatherz/luanxml
|
||||
[map.runfast.stream]: https://map.runfast.stream
|
||||
[Zatherz]: https://github.com/zatherz
|
||||
|
@ -1,8 +1,10 @@
|
||||
; Copyright (c) 2019-2022 David Vogel
|
||||
; Copyright (c) 2019-2024 David Vogel
|
||||
;
|
||||
; This software is released under the MIT License.
|
||||
; https://opensource.org/licenses/MIT
|
||||
|
||||
EnableExplicit
|
||||
|
||||
UsePNGImageEncoder()
|
||||
|
||||
Declare Worker(*Dummy)
|
||||
@ -15,44 +17,43 @@ Structure QueueElement
|
||||
sy.i
|
||||
EndStructure
|
||||
|
||||
; Source: https://www.purebasic.fr/english/viewtopic.php?f=13&t=29981&start=15
|
||||
Procedure EnumWindowsProc(hWnd.l, *lParam.Long)
|
||||
Protected lpProc.l
|
||||
GetWindowThreadProcessId_(hWnd, @lpProc)
|
||||
If *lParam\l = lpProc ; Check if current window's processID matches
|
||||
*lParam\l = hWnd ; Replace processID in the param With the hwnd As result
|
||||
ProcedureReturn #False ; Return false to stop iterating
|
||||
Structure GLViewportDims
|
||||
x.i
|
||||
y.i
|
||||
width.i
|
||||
height.i
|
||||
EndStructure
|
||||
|
||||
Structure WorkerInfo
|
||||
workerNumber.i
|
||||
EndStructure
|
||||
|
||||
#Workers = 8
|
||||
|
||||
; Returns the size of the main OpenGL rendering output.
|
||||
ProcedureDLL GetGLViewportSize(*dims.GLViewportDims)
|
||||
If Not *dims
|
||||
ProcedureReturn #False
|
||||
EndIf
|
||||
|
||||
glGetIntegerv_(#GL_VIEWPORT, *dims)
|
||||
|
||||
ProcedureReturn #True
|
||||
EndProcedure
|
||||
|
||||
; Source: https://www.purebasic.fr/english/viewtopic.php?f=13&t=29981&start=15
|
||||
; Returns the first window associated with the given process handle
|
||||
Procedure GetProcHwnd()
|
||||
Protected pID.l = GetCurrentProcessId_()
|
||||
Protected tempParam.l = pID
|
||||
EnumWindows_(@EnumWindowsProc(), @tempParam)
|
||||
If tempParam = pID ; Check if anything was found
|
||||
ProcedureReturn #Null
|
||||
EndIf
|
||||
ProcedureReturn tempParam ; This is a valid hWnd at this point
|
||||
EndProcedure
|
||||
|
||||
; Get the client rectangle of the "Main" window of this process in screen coordinates
|
||||
; Returns the size of the main OpenGL rendering output as a windows RECT.
|
||||
ProcedureDLL GetRect(*rect.RECT)
|
||||
Protected hWnd.l = GetProcHwnd()
|
||||
If Not hWnd
|
||||
ProcedureReturn #False
|
||||
EndIf
|
||||
If Not *rect
|
||||
ProcedureReturn #False
|
||||
EndIf
|
||||
|
||||
GetClientRect_(hWnd, *rect)
|
||||
Protected dims.GLViewportDims
|
||||
glGetIntegerv_(#GL_VIEWPORT, dims)
|
||||
|
||||
; A RECT consists basically of two POINT structures
|
||||
ClientToScreen_(hWnd, @*rect\left)
|
||||
ClientToScreen_(hWnd, @*rect\Right)
|
||||
*rect\left = dims\x
|
||||
*rect\top = dims\y
|
||||
*rect\right = dims\x + dims\width
|
||||
*rect\bottom = dims\y + dims\height
|
||||
|
||||
ProcedureReturn #True
|
||||
EndProcedure
|
||||
@ -64,13 +65,16 @@ ProcedureDLL AttachProcess(Instance)
|
||||
|
||||
CreateDirectory("mods/noita-mapcap/output/")
|
||||
|
||||
For i = 1 To 6
|
||||
CreateThread(@Worker(), #Null)
|
||||
Static Dim WorkerInfos.WorkerInfo(#Workers-1)
|
||||
Protected i
|
||||
For i = 0 To #Workers-1
|
||||
WorkerInfos(i)\workerNumber = i
|
||||
CreateThread(@Worker(), @WorkerInfos(i))
|
||||
Next
|
||||
EndProcedure
|
||||
|
||||
Procedure Worker(*Dummy)
|
||||
Protected img, x, y
|
||||
Procedure Worker(*workerInfo.WorkerInfo)
|
||||
Protected img, x, y, sx, sy
|
||||
|
||||
Repeat
|
||||
WaitSemaphore(Semaphore)
|
||||
@ -89,61 +93,60 @@ Procedure Worker(*Dummy)
|
||||
ResizeImage(img, sx, sy)
|
||||
EndIf
|
||||
|
||||
SaveImage(img, "mods/noita-mapcap/output/" + x + "," + y + ".png", #PB_ImagePlugin_PNG)
|
||||
;SaveImage(img, "" + x + "," + y + ".png", #PB_ImagePlugin_PNG) ; Test
|
||||
; Save image temporary, and only move it once it's fully exported.
|
||||
; This prevents images getting corrupted when the main process crashes.
|
||||
If SaveImage(img, "mods/noita-mapcap/output/worker_" + *workerInfo\workerNumber + ".tmp", #PB_ImagePlugin_PNG)
|
||||
RenameFile("mods/noita-mapcap/output/worker_" + *workerInfo\workerNumber + ".tmp", "mods/noita-mapcap/output/" + x + "," + y + ".png")
|
||||
; We can't really do anything when either SaveImage or RenameFile fails, so just silently fail.
|
||||
EndIf
|
||||
|
||||
FreeImage(img)
|
||||
ForEver
|
||||
EndProcedure
|
||||
|
||||
; Takes a screenshot of the client area of this process' active window.
|
||||
; The portion of the client area that is captured is described by capRect, which is in window coordinates and relative to the client area.
|
||||
; The portion of the client area that is captured is described by capRect, which is in viewport coordinates.
|
||||
; x and y defines the top left position of the captured rectangle in scaled world coordinates. The scale depends on the window to world pixel ratio.
|
||||
; sx and sy defines the final dimensions that the screenshot will be resized to. No resize will happen if set to 0.
|
||||
ProcedureDLL Capture(*capRect.RECT, x.l, y.l, sx.l, sy.l)
|
||||
Protected hWnd.l = GetProcHwnd()
|
||||
If Not hWnd
|
||||
Protected viewportRect.RECT
|
||||
If Not GetRect(@viewportRect)
|
||||
ProcedureReturn #False
|
||||
EndIf
|
||||
|
||||
Protected rect.RECT
|
||||
If Not GetRect(@rect)
|
||||
ProcedureReturn #False
|
||||
EndIf
|
||||
Protected imageID, hDC, *pixelBuffer
|
||||
|
||||
; Limit the desired capture area to the actual client area of the window.
|
||||
; Limit the desired capture area to the actual client area of the viewport.
|
||||
If *capRect\left < 0 : *capRect\left = 0 : EndIf
|
||||
If *capRect\right > rect\right-rect\left : *capRect\right = rect\right-rect\left : EndIf
|
||||
If *capRect\top < 0 : *capRect\top = 0 : EndIf
|
||||
If *capRect\bottom > rect\bottom-rect\top : *capRect\bottom = rect\bottom-rect\top : EndIf
|
||||
If *capRect\right < *capRect\left : *capRect\right = *capRect\left : EndIf
|
||||
If *capRect\bottom < *capRect\top : *capRect\bottom = *capRect\top : EndIf
|
||||
If *capRect\right > viewportRect\right : *capRect\right = viewportRect\right : EndIf
|
||||
If *capRect\bottom > viewportRect\bottom : *capRect\bottom = viewportRect\bottom : EndIf
|
||||
|
||||
imageID = CreateImage(#PB_Any, *capRect\right-*capRect\left, *capRect\bottom-*capRect\top)
|
||||
Protected capWidth = *capRect\right - *capRect\left
|
||||
Protected capHeight = *capRect\bottom - *capRect\top
|
||||
|
||||
imageID = CreateImage(#PB_Any, capWidth, capHeight)
|
||||
If Not imageID
|
||||
ProcedureReturn #False
|
||||
EndIf
|
||||
|
||||
; Get DC of window.
|
||||
windowDC = GetDC_(hWnd)
|
||||
If Not windowDC
|
||||
FreeImage(imageID)
|
||||
ProcedureReturn #False
|
||||
EndIf
|
||||
|
||||
hDC = StartDrawing(ImageOutput(imageID))
|
||||
If Not hDC
|
||||
ReleaseDC_(hWnd, windowDC)
|
||||
FreeImage(imageID)
|
||||
ProcedureReturn #False
|
||||
EndIf
|
||||
If Not BitBlt_(hDC, 0, 0, *capRect\right-*capRect\left, *capRect\bottom-*capRect\top, windowDC, *capRect\left, *capRect\top, #SRCCOPY) ; After some time BitBlt will fail, no idea why. Also, that's moments before noita crashes.
|
||||
StopDrawing()
|
||||
ReleaseDC_(hWnd, windowDC)
|
||||
FreeImage(imageID)
|
||||
ProcedureReturn #False
|
||||
EndIf
|
||||
StopDrawing()
|
||||
|
||||
ReleaseDC_(hWnd, windowDC)
|
||||
*pixelBuffer = DrawingBuffer()
|
||||
glReadPixels_(*capRect\left, *capRect\top, capWidth, capHeight, #GL_BGR_EXT, #GL_UNSIGNED_BYTE, *pixelBuffer)
|
||||
If glGetError_() <> #GL_NO_ERROR
|
||||
StopDrawing()
|
||||
FreeImage(imageID)
|
||||
ProcedureReturn #False
|
||||
EndIf
|
||||
|
||||
StopDrawing()
|
||||
|
||||
LockMutex(Mutex)
|
||||
; Check if the queue has too many elements, if so, wait. (Emulate go's channels)
|
||||
@ -173,13 +176,14 @@ EndProcedure
|
||||
;Capture(123, 123)
|
||||
;Delay(1000)
|
||||
|
||||
; IDE Options = PureBasic 6.00 LTS (Windows - x64)
|
||||
; IDE Options = PureBasic 6.04 LTS (Windows - x64)
|
||||
; ExecutableFormat = Shared dll
|
||||
; CursorPosition = 94
|
||||
; FirstLine = 39
|
||||
; Folding = --
|
||||
; CursorPosition = 99
|
||||
; FirstLine = 72
|
||||
; Folding = -
|
||||
; Optimizer
|
||||
; EnableThread
|
||||
; EnableXP
|
||||
; Executable = capture.dll
|
||||
; Compiler = PureBasic 6.00 LTS (Windows - x86)
|
||||
; DisableDebugger
|
||||
; Compiler = PureBasic 6.04 LTS - C Backend (Windows - x86)
|
Binary file not shown.
@ -1 +0,0 @@
|
||||
go tool pprof -http=: ./stitch.exe cpu.prof
|
@ -17,7 +17,7 @@ The source images need to contain their coordinates in the filename, as this pro
|
||||
|
||||
example list of files:
|
||||
|
||||
``` Shell Session
|
||||
``` Text
|
||||
0,0.png
|
||||
512,0.png
|
||||
-512,0.png
|
||||
@ -29,11 +29,27 @@ example list of files:
|
||||
- Either run the program and follow the interactive prompt.
|
||||
- Or run the program with parameters:
|
||||
- `divide int`
|
||||
A downscaling factor. 2 will produce an image with half the side lengths. (default 1)
|
||||
A downscaling factor. 2 will produce an image with half the side lengths. Defaults to 1.
|
||||
- `blend-tile-limit int`
|
||||
Limits median blending to the n newest tiles by file modification time.
|
||||
If set to 0, all available tiles will be median blended.
|
||||
If set to 1, only the newest tile will be used for any resulting pixel.
|
||||
Use 1 to prevent ghosting and blurry objects.
|
||||
- `input string`
|
||||
The source path of the image tiles to be stitched. (default "..\\..\\output")
|
||||
The source path of the image tiles to be stitched. Defaults to "./..//..//output"
|
||||
- `entities string`
|
||||
The path to the `entities.json` file. This contains Noita specific entity data. Defaults to "./../../output/entities.json".
|
||||
- `player-path string`
|
||||
The path to the player-path.json file. This contains the tracked path of the player. Defaults to "./../../output/player-path.json".
|
||||
- `output string`
|
||||
The path and filename of the resulting stitched image. (default "output.png")
|
||||
The path and filename of the resulting stitched image. Defaults to "output.png".
|
||||
Supported formats/file extensions: `.png`, `.webp`, `.jpg`, `.dzi`.
|
||||
- `dzi-tile-size`
|
||||
The size of the resulting deep zoom image (DZI) tiles in pixels. Defaults to 512.
|
||||
- `dzi-tile-overlap`
|
||||
The number of additional pixels around every deep zoom image (DZI) tile. Defaults to 2.
|
||||
- `webp-level`
|
||||
Compression level of WebP files, from 0 (fast) to 9 (slow, best compression). Defaults to 8.
|
||||
- `xmax int`
|
||||
Right bound of the output rectangle. This coordinate is not included in the output.
|
||||
- `xmin int`
|
||||
@ -42,10 +58,6 @@ example list of files:
|
||||
Lower bound of the output rectangle. This coordinate is not included in the output.
|
||||
- `ymin int`
|
||||
Upper bound of the output rectangle. This coordinate is included in the output.
|
||||
- `prerender`
|
||||
Pre renders the image in RAM before saving. Can speed things up if you have enough RAM.
|
||||
- `cleanup float`
|
||||
Enables cleanup mode with the given float as threshold. This will **DELETE** images from the input folder; no stitching will be done in this mode. A good value to start with is `0.999`, which deletes images where the sum of the min-max difference of each sub-pixel overlapping with other images is less than 99.9%% of the maximum possible sum of pixel differences.
|
||||
|
||||
To output the 100x100 area that is centered at the origin use:
|
||||
|
||||
@ -53,13 +65,13 @@ To output the 100x100 area that is centered at the origin use:
|
||||
./stitch -divide 1 -xmin -50 -xmax 50 -ymin -50 -ymax 50
|
||||
```
|
||||
|
||||
To remove images that would cause artifacts (You should recapture the deleted images afterwards):
|
||||
To output a [Deep Zoom Image (DZI)](https://en.wikipedia.org/wiki/Deep_Zoom), which can be used with [OpenSeadragon](https://openseadragon.github.io/examples/tilesource-dzi/), use:
|
||||
|
||||
``` Shell Session
|
||||
./stitch -cleanup 0.999
|
||||
./stitch -output capture.dzi
|
||||
```
|
||||
|
||||
To enter the parameters inside of the program:
|
||||
To start the program interactively:
|
||||
|
||||
``` Shell Session
|
||||
./stitch
|
||||
|
164
bin/stitch/blend-methods.go
Normal file
164
bin/stitch/blend-methods.go
Normal file
@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2022-2024 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// BlendMethodMedian takes the given tiles and median blends them into destImage.
|
||||
type BlendMethodMedian struct {
|
||||
BlendTileLimit int // If larger than 0, limits median blending to the n newest tiles by file modification time.
|
||||
}
|
||||
|
||||
// Draw implements the StitchedImageBlendMethod interface.
|
||||
func (b BlendMethodMedian) Draw(tiles []*ImageTile, destImage *image.RGBA) {
|
||||
bounds := destImage.Bounds()
|
||||
|
||||
if b.BlendTileLimit > 0 {
|
||||
// Sort tiles by date.
|
||||
sort.Slice(tiles, func(i, j int) bool { return tiles[i].modTime.After(tiles[j].modTime) })
|
||||
}
|
||||
|
||||
// List of images corresponding with every tile.
|
||||
// Can contain empty/nil entries for images that failed to load.
|
||||
images := []*image.RGBA{}
|
||||
for _, tile := range tiles {
|
||||
images = append(images, tile.GetImage())
|
||||
}
|
||||
|
||||
// Create arrays to be reused every pixel.
|
||||
rListEmpty, gListEmpty, bListEmpty := make([]uint8, 0, len(tiles)), make([]uint8, 0, len(tiles)), make([]uint8, 0, len(tiles))
|
||||
|
||||
for iy := bounds.Min.Y; iy < bounds.Max.Y; iy++ {
|
||||
for ix := bounds.Min.X; ix < bounds.Max.X; ix++ {
|
||||
rList, gList, bList := rListEmpty, gListEmpty, bListEmpty
|
||||
point := image.Point{ix, iy}
|
||||
count := 0
|
||||
|
||||
// Iterate through all images and create a list of colors.
|
||||
for _, img := range images {
|
||||
if img != nil {
|
||||
if point.In(img.Bounds()) {
|
||||
col := img.RGBAAt(point.X, point.Y)
|
||||
rList, gList, bList = append(rList, col.R), append(gList, col.G), append(bList, col.B)
|
||||
count++
|
||||
// Limit number of tiles to median blend.
|
||||
// Will be ignored if the blend tile limit is 0.
|
||||
if count == b.BlendTileLimit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch count {
|
||||
case 0: // If there were no images to get data from, ignore the pixel.
|
||||
continue
|
||||
|
||||
case 1: // Only a single tile for this pixel.
|
||||
r, g, b := uint8(rList[0]), uint8(gList[0]), uint8(bList[0])
|
||||
destImage.SetRGBA(ix, iy, color.RGBA{r, g, b, 255})
|
||||
|
||||
default: // Multiple overlapping tiles, median blend them.
|
||||
var r, g, b uint8
|
||||
switch count % 2 {
|
||||
case 0: // Even.
|
||||
r = uint8((int(QuickSelectUInt8(rList, count/2-1)) + int(QuickSelectUInt8(rList, count/2))) / 2)
|
||||
g = uint8((int(QuickSelectUInt8(gList, count/2-1)) + int(QuickSelectUInt8(gList, count/2))) / 2)
|
||||
b = uint8((int(QuickSelectUInt8(bList, count/2-1)) + int(QuickSelectUInt8(bList, count/2))) / 2)
|
||||
default: // Odd.
|
||||
r = QuickSelectUInt8(rList, count/2)
|
||||
g = QuickSelectUInt8(gList, count/2)
|
||||
b = QuickSelectUInt8(bList, count/2)
|
||||
}
|
||||
destImage.SetRGBA(ix, iy, color.RGBA{r, g, b, 255})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BlendMethodVoronoi maps every pixel to the tile with the closest center point distance.
|
||||
// The result is basically a Voronoi partitioning.
|
||||
type BlendMethodVoronoi struct {
|
||||
BlendTileLimit int // If larger than 0, limits blending to the n newest tiles by file modification time.
|
||||
}
|
||||
|
||||
// Draw implements the StitchedImageBlendMethod interface.
|
||||
func (b BlendMethodVoronoi) Draw(tiles []*ImageTile, destImage *image.RGBA) {
|
||||
bounds := destImage.Bounds()
|
||||
|
||||
if b.BlendTileLimit > 0 {
|
||||
// Sort tiles by date.
|
||||
sort.Slice(tiles, func(i, j int) bool { return tiles[i].modTime.After(tiles[j].modTime) })
|
||||
}
|
||||
|
||||
// List of images corresponding to the "tiles" list.
|
||||
// Can contain empty/nil entries for images that failed to load.
|
||||
images := []*image.RGBA{}
|
||||
for _, tile := range tiles {
|
||||
images = append(images, tile.GetImage())
|
||||
}
|
||||
|
||||
// Create color variables reused every pixel.
|
||||
var col color.RGBA
|
||||
var centerDistSqrMin int
|
||||
|
||||
for iy := bounds.Min.Y; iy < bounds.Max.Y; iy++ {
|
||||
for ix := bounds.Min.X; ix < bounds.Max.X; ix++ {
|
||||
point := image.Point{ix, iy}
|
||||
count := 0
|
||||
centerDistSqrMin = math.MaxInt
|
||||
|
||||
// Iterate through all images and create a list of colors.
|
||||
for _, img := range images {
|
||||
if img != nil {
|
||||
if point.In(img.Bounds()) {
|
||||
center := img.Bounds().Min.Add(img.Bounds().Max).Div(2)
|
||||
centerDiff := point.Sub(center)
|
||||
distSqr := centerDiff.X*centerDiff.X + centerDiff.Y*centerDiff.Y
|
||||
if centerDistSqrMin > distSqr {
|
||||
centerDistSqrMin = distSqr
|
||||
col = img.RGBAAt(point.X, point.Y)
|
||||
}
|
||||
count++
|
||||
// Limit number of tiles to blend.
|
||||
// Will be ignored if the blend tile limit is 0.
|
||||
if count == b.BlendTileLimit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there were no images to get data from, ignore the pixel.
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
col.A = 255
|
||||
destImage.SetRGBA(ix, iy, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BlendMethodFast just draws all tiles into the destination image.
|
||||
// No mixing is done, and this is very fast when there is no or minimal tile overlap.
|
||||
type BlendMethodFast struct{}
|
||||
|
||||
// Draw implements the StitchedImageBlendMethod interface.
|
||||
func (b BlendMethodFast) Draw(tiles []*ImageTile, destImage *image.RGBA) {
|
||||
for _, tile := range tiles {
|
||||
if image := tile.GetImage(); image != nil {
|
||||
bounds := image.Bounds()
|
||||
draw.Draw(destImage, bounds, image, bounds.Min, draw.Src)
|
||||
}
|
||||
}
|
||||
}
|
208
bin/stitch/dzi.go
Normal file
208
bin/stitch/dzi.go
Normal file
@ -0,0 +1,208 @@
|
||||
// Copyright (c) 2023-2024 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/cheggaaa/pb/v3"
|
||||
)
|
||||
|
||||
type DZI struct {
|
||||
stitchedImage *StitchedImage
|
||||
|
||||
fileExtension string
|
||||
|
||||
tileSize int // The (maximum) width and height of a tile in pixels, not including the overlap.
|
||||
overlap int // The amount of additional pixels on every side of every tile. The real (max) width/height of an image is `2*overlap + tileSize`.
|
||||
|
||||
maxZoomLevel int // The maximum zoom level that is needed.
|
||||
}
|
||||
|
||||
// NewDZI creates a new DZI from the given StitchedImages.
|
||||
//
|
||||
// dziTileSize and dziOverlap define the size and overlap of the resulting DZI tiles.
|
||||
func NewDZI(stitchedImage *StitchedImage, dziTileSize, dziOverlap int) DZI {
|
||||
dzi := DZI{
|
||||
stitchedImage: stitchedImage,
|
||||
|
||||
fileExtension: ".webp",
|
||||
|
||||
overlap: dziOverlap,
|
||||
tileSize: dziTileSize,
|
||||
}
|
||||
|
||||
width, height := stitchedImage.bounds.Dx(), stitchedImage.bounds.Dy()
|
||||
|
||||
// Calculate max zoom level and stuff.
|
||||
neededLength := max(width, height)
|
||||
var sideLength int = 1
|
||||
var level int
|
||||
for sideLength < neededLength {
|
||||
level += 1
|
||||
sideLength *= 2
|
||||
}
|
||||
dzi.maxZoomLevel = level
|
||||
//dzi.maxZoomLevelLength = sideLength
|
||||
|
||||
return dzi
|
||||
}
|
||||
|
||||
// ExportDZIDescriptor exports the descriptive JSON file at the given path.
|
||||
func (d DZI) ExportDZIDescriptor(outputPath string) error {
|
||||
log.Printf("Creating DZI descriptor %q.", outputPath)
|
||||
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Prepare data that describes the layout of the image files.
|
||||
var dziDescriptor struct {
|
||||
Image struct {
|
||||
XMLNS string `json:"xmlns"`
|
||||
Format string
|
||||
Overlap string
|
||||
TileSize string
|
||||
Size struct {
|
||||
Width string
|
||||
Height string
|
||||
}
|
||||
TopLeft struct {
|
||||
X string
|
||||
Y string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dziDescriptor.Image.XMLNS = "http://schemas.microsoft.com/deepzoom/2008"
|
||||
dziDescriptor.Image.Format = "webp"
|
||||
dziDescriptor.Image.Overlap = strconv.Itoa(d.overlap)
|
||||
dziDescriptor.Image.TileSize = strconv.Itoa(d.tileSize)
|
||||
dziDescriptor.Image.Size.Width = strconv.Itoa(d.stitchedImage.bounds.Dx())
|
||||
dziDescriptor.Image.Size.Height = strconv.Itoa(d.stitchedImage.bounds.Dy())
|
||||
dziDescriptor.Image.TopLeft.X = strconv.Itoa(d.stitchedImage.bounds.Min.X)
|
||||
dziDescriptor.Image.TopLeft.Y = strconv.Itoa(d.stitchedImage.bounds.Min.Y)
|
||||
|
||||
jsonEnc := json.NewEncoder(f)
|
||||
return jsonEnc.Encode(dziDescriptor)
|
||||
}
|
||||
|
||||
// ExportDZITiles exports the single image tiles for every zoom level.
|
||||
func (d DZI) ExportDZITiles(outputDir string, bar *pb.ProgressBar, webPLevel int) error {
|
||||
log.Printf("Creating DZI tiles in %q.", outputDir)
|
||||
|
||||
const scaleDivider = 2
|
||||
|
||||
var exportedTiles atomic.Int64
|
||||
|
||||
// If there is a progress bar, start a goroutine that regularly updates it.
|
||||
// We will base that on the number of exported tiles.
|
||||
if bar != nil {
|
||||
|
||||
// Count final number of tiles.
|
||||
bounds := d.stitchedImage.bounds
|
||||
var finalTiles int64
|
||||
for zoomLevel := d.maxZoomLevel; zoomLevel >= 0; zoomLevel-- {
|
||||
for iY := 0; iY <= (bounds.Dy()-1)/d.tileSize; iY++ {
|
||||
for iX := 0; iX <= (bounds.Dx()-1)/d.tileSize; iX++ {
|
||||
finalTiles++
|
||||
}
|
||||
}
|
||||
bounds = image.Rect(DivideFloor(bounds.Min.X, scaleDivider), DivideFloor(bounds.Min.Y, scaleDivider), DivideCeil(bounds.Max.X, scaleDivider), DivideCeil(bounds.Max.Y, scaleDivider))
|
||||
}
|
||||
bar.SetRefreshRate(250 * time.Millisecond).SetTotal(finalTiles).Start()
|
||||
|
||||
done := make(chan struct{})
|
||||
defer func() {
|
||||
done <- struct{}{}
|
||||
bar.SetCurrent(bar.Total()).Finish()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(250 * time.Millisecond)
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
bar.SetCurrent(exportedTiles.Load())
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Start with the highest zoom level (Where every world pixel is exactly mapped into one image pixel).
|
||||
// Generate all tiles for this level, and then stitch another image (scaled down by a factor of 2) based on the previously generated tiles.
|
||||
// Repeat this process until we have generated level 0.
|
||||
|
||||
// The current stitched image we are working with.
|
||||
stitchedImage := d.stitchedImage
|
||||
|
||||
for zoomLevel := d.maxZoomLevel; zoomLevel >= 0; zoomLevel-- {
|
||||
|
||||
levelBasePath := filepath.Join(outputDir, fmt.Sprintf("%d", zoomLevel))
|
||||
if err := os.MkdirAll(levelBasePath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create zoom level base directory %q: %w", levelBasePath, err)
|
||||
}
|
||||
|
||||
// Store list of tiles, so that we can reuse them in the next step for the smaller zoom level.
|
||||
imageTiles := ImageTiles{}
|
||||
|
||||
// Export tiles.
|
||||
lg := NewLimitGroup(runtime.NumCPU())
|
||||
for iY := 0; iY <= (stitchedImage.bounds.Dy()-1)/d.tileSize; iY++ {
|
||||
for iX := 0; iX <= (stitchedImage.bounds.Dx()-1)/d.tileSize; iX++ {
|
||||
rect := image.Rect(iX*d.tileSize, iY*d.tileSize, iX*d.tileSize+d.tileSize, iY*d.tileSize+d.tileSize)
|
||||
rect = rect.Add(stitchedImage.bounds.Min)
|
||||
rect = rect.Inset(-d.overlap)
|
||||
img := stitchedImage.SubStitchedImage(rect)
|
||||
filePath := filepath.Join(levelBasePath, fmt.Sprintf("%d_%d%s", iX, iY, d.fileExtension))
|
||||
|
||||
lg.Add(1)
|
||||
go func() {
|
||||
defer lg.Done()
|
||||
if err := exportWebP(img, filePath, webPLevel); err != nil {
|
||||
log.Printf("Failed to export WebP: %v", err)
|
||||
}
|
||||
exportedTiles.Add(1)
|
||||
}()
|
||||
|
||||
imageTiles = append(imageTiles, ImageTile{
|
||||
fileName: filePath,
|
||||
modTime: time.Now(),
|
||||
scaleDivider: scaleDivider,
|
||||
image: image.Rect(DivideFloor(img.Bounds().Min.X, scaleDivider), DivideFloor(img.Bounds().Min.Y, scaleDivider), DivideCeil(img.Bounds().Max.X, scaleDivider), DivideCeil(img.Bounds().Max.Y, scaleDivider)),
|
||||
imageMutex: &sync.RWMutex{},
|
||||
invalidationChan: make(chan struct{}, 1),
|
||||
timeoutChan: make(chan struct{}, 1),
|
||||
})
|
||||
}
|
||||
}
|
||||
lg.Wait()
|
||||
|
||||
// Create new stitched image from the previously exported tiles.
|
||||
// The tiles are already created in a way, that they are scaled down by a factor of 2.
|
||||
var err error
|
||||
stitchedImage, err = NewStitchedImage(imageTiles, imageTiles.Bounds(), BlendMethodFast{}, 128, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run NewStitchedImage(): %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
63
bin/stitch/entities.go
Normal file
63
bin/stitch/entities.go
Normal file
@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2022 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"image"
|
||||
"os"
|
||||
|
||||
"github.com/tdewolff/canvas"
|
||||
"github.com/tdewolff/canvas/renderers/rasterizer"
|
||||
)
|
||||
|
||||
type Entities []Entity
|
||||
|
||||
func LoadEntities(path string) (Entities, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result Entities
|
||||
|
||||
jsonDec := json.NewDecoder(file)
|
||||
if err := jsonDec.Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Draw implements the StitchedImageOverlay interface.
|
||||
func (e Entities) Draw(destImage *image.RGBA) {
|
||||
destRect := destImage.Bounds()
|
||||
|
||||
// Same as destImage, but top left is translated to (0, 0).
|
||||
originImage := destImage.SubImage(destRect).(*image.RGBA)
|
||||
originImage.Rect = originImage.Rect.Sub(destRect.Min)
|
||||
|
||||
c := canvas.New(float64(destRect.Dx()), float64(destRect.Dy()))
|
||||
ctx := canvas.NewContext(c)
|
||||
ctx.SetCoordSystem(canvas.CartesianIV)
|
||||
ctx.SetCoordRect(canvas.Rect{X: -float64(destRect.Min.X), Y: -float64(destRect.Min.Y), W: float64(destRect.Dx()), H: float64(destRect.Dy())}, float64(destRect.Dx()), float64(destRect.Dy()))
|
||||
|
||||
// Set drawing style.
|
||||
ctx.Style = playerPathDisplayStyle
|
||||
|
||||
for _, entity := range e {
|
||||
// Check if entity origin is near or around the current image rectangle.
|
||||
entityOrigin := image.Point{int(entity.Transform.X), int(entity.Transform.Y)}
|
||||
if entityOrigin.In(destRect.Inset(-512)) {
|
||||
entity.Draw(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Theoretically we would need to linearize imgRGBA first, but DefaultColorSpace assumes that the color space is linear already.
|
||||
r := rasterizer.FromImage(originImage, canvas.DPMM(1.0), canvas.DefaultColorSpace)
|
||||
c.RenderTo(r)
|
||||
r.Close() // This just transforms the image's luminance curve back from linear into non linear.
|
||||
}
|
220
bin/stitch/entity.go
Normal file
220
bin/stitch/entity.go
Normal file
@ -0,0 +1,220 @@
|
||||
// Copyright (c) 2022 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"github.com/tdewolff/canvas"
|
||||
)
|
||||
|
||||
//var entityDisplayFontFamily = canvas.NewFontFamily("times")
|
||||
//var entityDisplayFontFace *canvas.FontFace
|
||||
|
||||
var entityDisplayAreaDamageStyle = canvas.Style{
|
||||
Fill: canvas.Paint{Color: color.RGBA{100, 0, 0, 100}},
|
||||
Stroke: canvas.Paint{},
|
||||
StrokeWidth: 1.0,
|
||||
StrokeCapper: canvas.ButtCap,
|
||||
StrokeJoiner: canvas.MiterJoin,
|
||||
DashOffset: 0.0,
|
||||
Dashes: []float64{},
|
||||
FillRule: canvas.NonZero,
|
||||
}
|
||||
|
||||
var entityDisplayMaterialAreaCheckerStyle = canvas.Style{
|
||||
Fill: canvas.Paint{Color: color.RGBA{0, 0, 127, 127}},
|
||||
Stroke: canvas.Paint{},
|
||||
StrokeWidth: 1.0,
|
||||
StrokeCapper: canvas.ButtCap,
|
||||
StrokeJoiner: canvas.MiterJoin,
|
||||
DashOffset: 0.0,
|
||||
Dashes: []float64{},
|
||||
FillRule: canvas.NonZero,
|
||||
}
|
||||
|
||||
var entityDisplayTeleportStyle = canvas.Style{
|
||||
Fill: canvas.Paint{Color: color.RGBA{0, 127, 0, 127}},
|
||||
Stroke: canvas.Paint{},
|
||||
StrokeWidth: 1.0,
|
||||
StrokeCapper: canvas.ButtCap,
|
||||
StrokeJoiner: canvas.MiterJoin,
|
||||
DashOffset: 0.0,
|
||||
Dashes: []float64{},
|
||||
FillRule: canvas.NonZero,
|
||||
}
|
||||
|
||||
var entityDisplayHitBoxStyle = canvas.Style{
|
||||
Fill: canvas.Paint{Color: color.RGBA{64, 64, 0, 64}},
|
||||
Stroke: canvas.Paint{Color: color.RGBA{0, 0, 0, 64}},
|
||||
StrokeWidth: 1.0,
|
||||
StrokeCapper: canvas.ButtCap,
|
||||
StrokeJoiner: canvas.MiterJoin,
|
||||
DashOffset: 0.0,
|
||||
Dashes: []float64{},
|
||||
FillRule: canvas.NonZero,
|
||||
}
|
||||
|
||||
var entityDisplayCollisionTriggerStyle = canvas.Style{
|
||||
Fill: canvas.Paint{Color: color.RGBA{0, 64, 64, 64}},
|
||||
Stroke: canvas.Paint{Color: color.RGBA{0, 0, 0, 64}},
|
||||
StrokeWidth: 1.0,
|
||||
StrokeCapper: canvas.ButtCap,
|
||||
StrokeJoiner: canvas.MiterJoin,
|
||||
DashOffset: 0.0,
|
||||
Dashes: []float64{},
|
||||
FillRule: canvas.NonZero,
|
||||
}
|
||||
|
||||
func init() {
|
||||
//fontName := "NimbusRoman-Regular"
|
||||
|
||||
//if err := entityDisplayFontFamily.LoadLocalFont(fontName, canvas.FontRegular); err != nil {
|
||||
// log.Printf("Couldn't load font %q: %v", fontName, err)
|
||||
//}
|
||||
|
||||
//entityDisplayFontFace = entityDisplayFontFamily.Face(48.0, canvas.White, canvas.FontRegular, canvas.FontNormal)
|
||||
}
|
||||
|
||||
type Entity struct {
|
||||
Filename string `json:"filename"`
|
||||
Transform EntityTransform `json:"transform"`
|
||||
Children []Entity `json:"children"`
|
||||
Components []Component `json:"components"`
|
||||
Name string `json:"name"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type EntityTransform struct {
|
||||
X float32 `json:"x"`
|
||||
Y float32 `json:"y"`
|
||||
ScaleX float32 `json:"scaleX"`
|
||||
ScaleY float32 `json:"scaleY"`
|
||||
Rotation float32 `json:"rotation"`
|
||||
}
|
||||
|
||||
type Component struct {
|
||||
TypeName string `json:"typeName"`
|
||||
Members map[string]any `json:"members"`
|
||||
}
|
||||
|
||||
func (e Entity) Draw(c *canvas.Context) {
|
||||
x, y := float64(e.Transform.X), float64(e.Transform.Y)
|
||||
|
||||
for _, component := range e.Components {
|
||||
switch component.TypeName {
|
||||
case "AreaDamageComponent": // Area damage like in cursed rock.
|
||||
var aabbMinX, aabbMinY, aabbMaxX, aabbMaxY float64
|
||||
if member, ok := component.Members["aabb_min"]; ok {
|
||||
if aabbMin, ok := member.([]any); ok && len(aabbMin) == 2 {
|
||||
aabbMinX, _ = aabbMin[0].(float64)
|
||||
aabbMinY, _ = aabbMin[1].(float64)
|
||||
}
|
||||
}
|
||||
if member, ok := component.Members["aabb_max"]; ok {
|
||||
if aabbMax, ok := member.([]any); ok && len(aabbMax) == 2 {
|
||||
aabbMaxX, _ = aabbMax[0].(float64)
|
||||
aabbMaxY, _ = aabbMax[1].(float64)
|
||||
}
|
||||
}
|
||||
if aabbMinX < aabbMaxX && aabbMinY < aabbMaxY {
|
||||
c.Style = entityDisplayAreaDamageStyle
|
||||
c.DrawPath(x+aabbMinX, y+aabbMinY, canvas.Rectangle(aabbMaxX-aabbMinX, aabbMaxY-aabbMinY))
|
||||
}
|
||||
if member, ok := component.Members["circle_radius"]; ok {
|
||||
if radius, ok := member.(float64); ok && radius > 0 {
|
||||
// Theoretically we need to clip the damage area to the intersection of the AABB and the circle, but meh.
|
||||
// TODO: Clip the area to the intersection of the box and the circle
|
||||
cx, cy := (aabbMinX+aabbMaxX)/2, (aabbMinY+aabbMaxY)/2
|
||||
c.Style = entityDisplayAreaDamageStyle
|
||||
c.DrawPath(x+cx, y+cy, canvas.Circle(radius))
|
||||
}
|
||||
}
|
||||
|
||||
case "MaterialAreaCheckerComponent": // Checks for materials in the given AABB.
|
||||
var aabbMinX, aabbMinY, aabbMaxX, aabbMaxY float64
|
||||
if member, ok := component.Members["area_aabb"]; ok {
|
||||
if aabb, ok := member.([]any); ok && len(aabb) == 4 {
|
||||
aabbMinX, _ = aabb[0].(float64)
|
||||
aabbMinY, _ = aabb[1].(float64)
|
||||
aabbMaxX, _ = aabb[2].(float64)
|
||||
aabbMaxY, _ = aabb[3].(float64)
|
||||
}
|
||||
}
|
||||
if aabbMinX < aabbMaxX && aabbMinY < aabbMaxY {
|
||||
c.Style = entityDisplayMaterialAreaCheckerStyle
|
||||
c.DrawPath(x+aabbMinX, y+aabbMinY, canvas.Rectangle(aabbMaxX-aabbMinX, aabbMaxY-aabbMinY))
|
||||
}
|
||||
|
||||
case "TeleportComponent":
|
||||
var aabbMinX, aabbMinY, aabbMaxX, aabbMaxY float64
|
||||
if member, ok := component.Members["source_location_camera_aabb"]; ok {
|
||||
if aabb, ok := member.([]any); ok && len(aabb) == 4 {
|
||||
aabbMinX, _ = aabb[0].(float64)
|
||||
aabbMinY, _ = aabb[1].(float64)
|
||||
aabbMaxX, _ = aabb[2].(float64)
|
||||
aabbMaxY, _ = aabb[3].(float64)
|
||||
}
|
||||
}
|
||||
if aabbMinX < aabbMaxX && aabbMinY < aabbMaxY {
|
||||
c.Style = entityDisplayTeleportStyle
|
||||
c.DrawPath(x+aabbMinX, y+aabbMinY, canvas.Rectangle(aabbMaxX-aabbMinX, aabbMaxY-aabbMinY))
|
||||
}
|
||||
|
||||
case "HitboxComponent": // General hit box component.
|
||||
var aabbMinX, aabbMinY, aabbMaxX, aabbMaxY float64
|
||||
if member, ok := component.Members["aabb_min_x"]; ok {
|
||||
aabbMinX, _ = member.(float64)
|
||||
}
|
||||
if member, ok := component.Members["aabb_min_y"]; ok {
|
||||
aabbMinY, _ = member.(float64)
|
||||
}
|
||||
if member, ok := component.Members["aabb_max_x"]; ok {
|
||||
aabbMaxX, _ = member.(float64)
|
||||
}
|
||||
if member, ok := component.Members["aabb_max_y"]; ok {
|
||||
aabbMaxY, _ = member.(float64)
|
||||
}
|
||||
if aabbMinX < aabbMaxX && aabbMinY < aabbMaxY {
|
||||
c.Style = entityDisplayHitBoxStyle
|
||||
c.DrawPath(x+aabbMinX, y+aabbMinY, canvas.Rectangle(aabbMaxX-aabbMinX, aabbMaxY-aabbMinY))
|
||||
}
|
||||
|
||||
case "CollisionTriggerComponent": // Checks if another entity is inside the given radius and box with the given width and height.
|
||||
var width, height float64
|
||||
path := &canvas.Path{}
|
||||
if member, ok := component.Members["width"]; ok {
|
||||
width, _ = member.(float64)
|
||||
}
|
||||
if member, ok := component.Members["height"]; ok {
|
||||
height, _ = member.(float64)
|
||||
}
|
||||
if width > 0 && height > 0 {
|
||||
path = canvas.Rectangle(width, height).Translate(-width/2, -height/2)
|
||||
}
|
||||
// Theoretically we need to clip the area to the intersection of the box and the circle, but meh.
|
||||
// TODO: Clip the area to the intersection of the box and the circle
|
||||
//if member, ok := component.Members["radius"]; ok {
|
||||
// if radius, ok := member.(float64); ok && radius > 0 {
|
||||
// path = path.Append(canvas.Circle(radius))
|
||||
// path.And()
|
||||
// }
|
||||
//}
|
||||
if !path.Empty() {
|
||||
c.Style = entityDisplayCollisionTriggerStyle
|
||||
c.DrawPath(x, y, path)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
c.SetFillColor(color.RGBA{255, 255, 255, 128})
|
||||
c.SetStrokeColor(color.RGBA{255, 0, 0, 255})
|
||||
c.DrawPath(x, y, canvas.Circle(3))
|
||||
|
||||
//text := canvas.NewTextLine(entityDisplayFontFace, fmt.Sprintf("%s\n%s", e.Name, e.Filename), canvas.Left)
|
||||
//c.DrawText(x, y, text)
|
||||
}
|
40
bin/stitch/export-dzi.go
Normal file
40
bin/stitch/export-dzi.go
Normal file
@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2023-2024 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/cheggaaa/pb/v3"
|
||||
)
|
||||
|
||||
func exportDZIStitchedImage(stitchedImage *StitchedImage, outputPath string, bar *pb.ProgressBar, dziTileSize, dziOverlap int, webPLevel int) error {
|
||||
descriptorPath := outputPath
|
||||
extension := filepath.Ext(outputPath)
|
||||
outputTilesPath := strings.TrimSuffix(outputPath, extension) + "_files"
|
||||
|
||||
dzi := NewDZI(stitchedImage, dziTileSize, dziOverlap)
|
||||
|
||||
// Create base directory of all DZI files.
|
||||
if err := os.MkdirAll(outputTilesPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
// Export DZI descriptor.
|
||||
if err := dzi.ExportDZIDescriptor(descriptorPath); err != nil {
|
||||
return fmt.Errorf("failed to export DZI descriptor: %w", err)
|
||||
}
|
||||
|
||||
// Export DZI tiles.
|
||||
if err := dzi.ExportDZITiles(outputTilesPath, bar, webPLevel); err != nil {
|
||||
return fmt.Errorf("failed to export DZI tiles: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
67
bin/stitch/export-jpeg.go
Normal file
67
bin/stitch/export-jpeg.go
Normal file
@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2023-2024 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/cheggaaa/pb/v3"
|
||||
)
|
||||
|
||||
func exportJPEGStitchedImage(stitchedImage *StitchedImage, outputPath string, bar *pb.ProgressBar) error {
|
||||
log.Printf("Creating output file %q.", outputPath)
|
||||
|
||||
// If there is a progress bar, start a goroutine that regularly updates it.
|
||||
// We will base the progress on the number of pixels read from the stitched image.
|
||||
if bar != nil {
|
||||
_, max := stitchedImage.Progress()
|
||||
bar.SetRefreshRate(250 * time.Millisecond).SetTotal(int64(max)).Start()
|
||||
|
||||
done := make(chan struct{})
|
||||
defer func() {
|
||||
done <- struct{}{}
|
||||
bar.SetCurrent(bar.Total()).Finish()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(250 * time.Millisecond)
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
value, max := stitchedImage.Progress()
|
||||
bar.SetCurrent(int64(value)).SetTotal(int64(max))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return exportJPEG(stitchedImage, outputPath)
|
||||
}
|
||||
|
||||
func exportJPEG(img image.Image, outputPath string) error {
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
options := &jpeg.Options{
|
||||
Quality: 80,
|
||||
}
|
||||
|
||||
if err := jpeg.Encode(f, img, options); err != nil {
|
||||
return fmt.Errorf("failed to encode image %q: %w", outputPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
67
bin/stitch/export-png.go
Normal file
67
bin/stitch/export-png.go
Normal file
@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2023-2024 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/cheggaaa/pb/v3"
|
||||
)
|
||||
|
||||
func exportPNGStitchedImage(stitchedImage *StitchedImage, outputPath string, bar *pb.ProgressBar) error {
|
||||
log.Printf("Creating output file %q.", outputPath)
|
||||
|
||||
// If there is a progress bar, start a goroutine that regularly updates it.
|
||||
// We will base the progress on the number of pixels read from the stitched image.
|
||||
if bar != nil {
|
||||
_, max := stitchedImage.Progress()
|
||||
bar.SetRefreshRate(250 * time.Millisecond).SetTotal(int64(max)).Start()
|
||||
|
||||
done := make(chan struct{})
|
||||
defer func() {
|
||||
done <- struct{}{}
|
||||
bar.SetCurrent(bar.Total()).Finish()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(250 * time.Millisecond)
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
value, max := stitchedImage.Progress()
|
||||
bar.SetCurrent(int64(value)).SetTotal(int64(max))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return exportPNG(stitchedImage, outputPath)
|
||||
}
|
||||
|
||||
func exportPNG(img image.Image, outputPath string) error {
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
encoder := png.Encoder{
|
||||
CompressionLevel: png.DefaultCompression,
|
||||
}
|
||||
|
||||
if err := encoder.Encode(f, img); err != nil {
|
||||
return fmt.Errorf("failed to encode image %q: %w", outputPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
73
bin/stitch/export-webp.go
Normal file
73
bin/stitch/export-webp.go
Normal file
@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2024 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/Dadido3/go-libwebp/webp"
|
||||
"github.com/cheggaaa/pb/v3"
|
||||
)
|
||||
|
||||
func exportWebPStitchedImage(stitchedImage *StitchedImage, outputPath string, bar *pb.ProgressBar, webPLevel int) error {
|
||||
log.Printf("Creating output file %q.", outputPath)
|
||||
|
||||
// If there is a progress bar, start a goroutine that regularly updates it.
|
||||
// We will base the progress on the number of pixels read from the stitched image.
|
||||
if bar != nil {
|
||||
_, max := stitchedImage.Progress()
|
||||
bar.SetRefreshRate(250 * time.Millisecond).SetTotal(int64(max)).Start()
|
||||
|
||||
done := make(chan struct{})
|
||||
defer func() {
|
||||
done <- struct{}{}
|
||||
bar.SetCurrent(bar.Total()).Finish()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(250 * time.Millisecond)
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
value, max := stitchedImage.Progress()
|
||||
bar.SetCurrent(int64(value)).SetTotal(int64(max))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return exportWebP(stitchedImage, outputPath, webPLevel)
|
||||
}
|
||||
|
||||
func exportWebP(img image.Image, outputPath string, webPLevel int) error {
|
||||
bounds := img.Bounds()
|
||||
if bounds.Dx() > 16383 || bounds.Dy() > 16383 {
|
||||
return fmt.Errorf("image size exceeds the maximum allowed size (16383) of a WebP image: %d x %d", bounds.Dx(), bounds.Dy())
|
||||
}
|
||||
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
webPConfig, err := webp.ConfigLosslessPreset(webPLevel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create webP config: %v", err)
|
||||
}
|
||||
|
||||
if err = webp.Encode(f, img, webPConfig); err != nil {
|
||||
return fmt.Errorf("failed to encode image %q: %w", outputPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
216
bin/stitch/image-tile.go
Normal file
216
bin/stitch/image-tile.go
Normal file
@ -0,0 +1,216 @@
|
||||
// Copyright (c) 2019-2022 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
_ "image/png"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nfnt/resize"
|
||||
)
|
||||
|
||||
var ImageTileFileRegex = regexp.MustCompile(`^(-?\d+),(-?\d+).png$`)
|
||||
|
||||
type ImageTile struct {
|
||||
fileName string
|
||||
modTime time.Time
|
||||
|
||||
scaleDivider int // Downscales the coordinates and images on the fly.
|
||||
|
||||
image image.Image // Either a rectangle or an RGBA image. The bounds of this image are determined by the filename.
|
||||
imageMutex *sync.RWMutex
|
||||
|
||||
invalidationChan chan struct{} // Used to send invalidation requests to the tile's goroutine.
|
||||
timeoutChan chan struct{} // Used to determine whether the tile is still being accessed or not.
|
||||
}
|
||||
|
||||
// NewImageTile returns an image tile object that represents the image at the given path.
|
||||
// The filename will be used to determine the top left x and y coordinate of the tile.
|
||||
// This will not load the image into RAM.
|
||||
func NewImageTile(path string, scaleDivider int) (ImageTile, error) {
|
||||
if scaleDivider < 1 {
|
||||
return ImageTile{}, fmt.Errorf("invalid scale of %v", scaleDivider)
|
||||
}
|
||||
|
||||
baseName := filepath.Base(path)
|
||||
result := ImageTileFileRegex.FindStringSubmatch(baseName)
|
||||
var x, y int
|
||||
if parsed, err := strconv.ParseInt(result[1], 10, 0); err == nil {
|
||||
x = int(parsed)
|
||||
} else {
|
||||
return ImageTile{}, fmt.Errorf("error parsing %q to integer: %w", result[1], err)
|
||||
}
|
||||
if parsed, err := strconv.ParseInt(result[2], 10, 0); err == nil {
|
||||
y = int(parsed)
|
||||
} else {
|
||||
return ImageTile{}, fmt.Errorf("error parsing %q to integer: %w", result[2], err)
|
||||
}
|
||||
|
||||
width, height, err := GetImageFileDimension(path)
|
||||
if err != nil {
|
||||
return ImageTile{}, err
|
||||
}
|
||||
|
||||
var modTime time.Time
|
||||
fileInfo, err := os.Lstat(path)
|
||||
if err == nil {
|
||||
modTime = fileInfo.ModTime()
|
||||
}
|
||||
|
||||
return ImageTile{
|
||||
fileName: path,
|
||||
modTime: modTime,
|
||||
scaleDivider: scaleDivider,
|
||||
image: image.Rect(DivideFloor(x, scaleDivider), DivideFloor(y, scaleDivider), DivideCeil(x+width, scaleDivider), DivideCeil(y+height, scaleDivider)),
|
||||
imageMutex: &sync.RWMutex{},
|
||||
invalidationChan: make(chan struct{}, 1),
|
||||
timeoutChan: make(chan struct{}, 1),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetImage returns an image.Image that contains the tile pixel data.
|
||||
// This will not return errors in case something went wrong, but will just return nil.
|
||||
// All errors are written to stdout.
|
||||
func (it *ImageTile) GetImage() *image.RGBA {
|
||||
it.imageMutex.RLock()
|
||||
|
||||
// Clear the timeout chan to signal that the image is still being used.
|
||||
select {
|
||||
case <-it.timeoutChan:
|
||||
default:
|
||||
}
|
||||
|
||||
// Check if the image is already loaded.
|
||||
if img, ok := it.image.(*image.RGBA); ok {
|
||||
it.imageMutex.RUnlock()
|
||||
return img
|
||||
}
|
||||
|
||||
it.imageMutex.RUnlock()
|
||||
// It's possible that the image got changed in between here.
|
||||
it.imageMutex.Lock()
|
||||
defer it.imageMutex.Unlock()
|
||||
|
||||
// Check again if the image is already loaded.
|
||||
if img, ok := it.image.(*image.RGBA); ok {
|
||||
return img
|
||||
}
|
||||
|
||||
// Store rectangle of the old image.
|
||||
oldRect := it.image.Bounds()
|
||||
|
||||
file, err := os.Open(it.fileName)
|
||||
if err != nil {
|
||||
log.Printf("Couldn't load file %q: %v.", it.fileName, err)
|
||||
return nil
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
img, _, err := image.Decode(file)
|
||||
if err != nil {
|
||||
log.Printf("Couldn't decode image %q: %v.", it.fileName, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if it.scaleDivider > 1 {
|
||||
img = resize.Resize(uint(oldRect.Dx()), uint(oldRect.Dy()), img, resize.NearestNeighbor)
|
||||
}
|
||||
|
||||
var imgRGBA *image.RGBA
|
||||
switch img := img.(type) {
|
||||
case *image.RGBA:
|
||||
imgRGBA = img
|
||||
case *image.NRGBA:
|
||||
bounds := img.Bounds()
|
||||
imgRGBA = image.NewRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy()))
|
||||
draw.Draw(imgRGBA, imgRGBA.Bounds(), img, bounds.Min, draw.Src)
|
||||
default:
|
||||
log.Printf("Expected an RGBA or NRGBA image for %q, got %T instead.", it.fileName, img)
|
||||
return nil
|
||||
}
|
||||
|
||||
imgRGBA.Rect = imgRGBA.Rect.Add(oldRect.Min)
|
||||
|
||||
it.image = imgRGBA
|
||||
|
||||
// Clear any old invalidation request.
|
||||
select {
|
||||
case <-it.invalidationChan:
|
||||
default:
|
||||
}
|
||||
|
||||
// Fill timeout channel with one element.
|
||||
// This is needed, as the ticker doesn't send a tick on initialization.
|
||||
select {
|
||||
case it.timeoutChan <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
// Free the image after some time or if requested externally.
|
||||
go func() {
|
||||
// Set up watchdog that checks if the image is being used.
|
||||
ticker := time.NewTicker(5000 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// Try to send to the timeout channel.
|
||||
select {
|
||||
case it.timeoutChan <- struct{}{}:
|
||||
default:
|
||||
// Timeout channel is full because the tile image wasn't queried recently.
|
||||
break loop
|
||||
}
|
||||
case <-it.invalidationChan:
|
||||
// An invalidation was requested externally.
|
||||
break loop
|
||||
}
|
||||
}
|
||||
|
||||
// Free image and other stuff.
|
||||
it.imageMutex.Lock()
|
||||
defer it.imageMutex.Unlock()
|
||||
it.image = it.image.Bounds()
|
||||
}()
|
||||
|
||||
return imgRGBA
|
||||
}
|
||||
|
||||
// Clears the cached image.
|
||||
func (it *ImageTile) Invalidate() {
|
||||
it.imageMutex.RLock()
|
||||
defer it.imageMutex.RUnlock()
|
||||
|
||||
// Try to send invalidation request.
|
||||
select {
|
||||
case it.invalidationChan <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// The scaled image boundaries.
|
||||
// This matches exactly to what GetImage() returns.
|
||||
func (it *ImageTile) Bounds() image.Rectangle {
|
||||
it.imageMutex.RLock()
|
||||
defer it.imageMutex.RUnlock()
|
||||
|
||||
return it.image.Bounds()
|
||||
}
|
||||
|
||||
func (it *ImageTile) String() string {
|
||||
return fmt.Sprintf("{ImageTile: %q}", it.fileName)
|
||||
}
|
63
bin/stitch/image-tiles.go
Normal file
63
bin/stitch/image-tiles.go
Normal file
@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2019-2023 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type ImageTiles []ImageTile
|
||||
|
||||
// LoadImageTiles "loads" all images in the directory at the given path.
|
||||
func LoadImageTiles(path string, scaleDivider int) (ImageTiles, error) {
|
||||
if scaleDivider < 1 {
|
||||
return nil, fmt.Errorf("invalid scale of %v", scaleDivider)
|
||||
}
|
||||
|
||||
var imageTiles ImageTiles
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(path, "*.png"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
imageTile, err := NewImageTile(file, scaleDivider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
imageTiles = append(imageTiles, imageTile)
|
||||
}
|
||||
|
||||
return imageTiles, nil
|
||||
}
|
||||
|
||||
// InvalidateAboveY invalidates all cached images that have no pixel at the given y coordinate or below.
|
||||
func (it ImageTiles) Bounds() image.Rectangle {
|
||||
totalBounds := image.Rectangle{}
|
||||
for i, tile := range it {
|
||||
if i == 0 {
|
||||
totalBounds = tile.Bounds()
|
||||
} else {
|
||||
totalBounds = totalBounds.Union(tile.Bounds())
|
||||
}
|
||||
}
|
||||
|
||||
return totalBounds
|
||||
}
|
||||
|
||||
// InvalidateAboveY invalidates all cached images that have no pixel at the given y coordinate or below.
|
||||
func (it ImageTiles) InvalidateAboveY(y int) {
|
||||
for i := range it {
|
||||
tile := &it[i] // Need to copy a reference.
|
||||
if tile.Bounds().Max.Y <= y {
|
||||
tile.Invalidate()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,113 +0,0 @@
|
||||
// Copyright (c) 2019-2022 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nfnt/resize"
|
||||
)
|
||||
|
||||
type imageTile struct {
|
||||
fileName string
|
||||
|
||||
scaleDivider int // Downscales the coordinates and images on the fly.
|
||||
|
||||
offset image.Point // Correction offset of the image, so that it aligns pixel perfect with other images. Determined by image matching.
|
||||
|
||||
image image.Image // Either a rectangle or an RGBA image. The bounds of this image are determined by the filename.
|
||||
imageMutex *sync.RWMutex //
|
||||
imageUsedFlag bool // Flag signalling, that the image was used recently
|
||||
|
||||
pixelErrorSum uint64 // Sum of the difference between the (sub)pixels of all overlapping images. 0 Means that all overlapping images are identical.
|
||||
}
|
||||
|
||||
func (it *imageTile) GetImage() (*image.RGBA, error) {
|
||||
it.imageMutex.RLock()
|
||||
|
||||
it.imageUsedFlag = true // Race condition may happen on this flag, but doesn't matter here.
|
||||
|
||||
// Check if the image is already loaded
|
||||
if img, ok := it.image.(*image.RGBA); ok {
|
||||
it.imageMutex.RUnlock()
|
||||
return img, nil
|
||||
}
|
||||
|
||||
it.imageMutex.RUnlock()
|
||||
// It's possible that the image got changed in between here
|
||||
it.imageMutex.Lock()
|
||||
defer it.imageMutex.Unlock()
|
||||
|
||||
// Check again if the image is already loaded
|
||||
if img, ok := it.image.(*image.RGBA); ok {
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// Store rectangle of the old image
|
||||
oldRect := it.image.Bounds()
|
||||
|
||||
file, err := os.Open(it.fileName)
|
||||
if err != nil {
|
||||
return &image.RGBA{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
img, _, err := image.Decode(file)
|
||||
if err != nil {
|
||||
return &image.RGBA{}, err
|
||||
}
|
||||
|
||||
if it.scaleDivider > 1 {
|
||||
img = resize.Resize(uint(oldRect.Dx()), uint(oldRect.Dy()), img, resize.NearestNeighbor)
|
||||
}
|
||||
|
||||
imgRGBA, ok := img.(*image.RGBA)
|
||||
if !ok {
|
||||
return &image.RGBA{}, fmt.Errorf("expected an RGBA image, got %T instead", img)
|
||||
}
|
||||
|
||||
// Restore the position of the image rectangle
|
||||
imgRGBA.Rect = imgRGBA.Rect.Add(oldRect.Min)
|
||||
|
||||
it.image = imgRGBA
|
||||
|
||||
// Free the image after some time
|
||||
go func() {
|
||||
for it.imageUsedFlag {
|
||||
time.Sleep(1 * time.Second)
|
||||
it.imageUsedFlag = false
|
||||
}
|
||||
|
||||
it.imageMutex.Lock()
|
||||
defer it.imageMutex.Unlock()
|
||||
it.image = it.image.Bounds()
|
||||
}()
|
||||
|
||||
return imgRGBA, nil
|
||||
}
|
||||
|
||||
func (it *imageTile) OffsetBounds() image.Rectangle {
|
||||
it.imageMutex.RLock()
|
||||
defer it.imageMutex.RUnlock()
|
||||
|
||||
return it.image.Bounds().Add(it.offset)
|
||||
}
|
||||
|
||||
func (it *imageTile) Bounds() image.Rectangle {
|
||||
it.imageMutex.RLock()
|
||||
defer it.imageMutex.RUnlock()
|
||||
|
||||
return it.image.Bounds()
|
||||
}
|
||||
|
||||
func (it *imageTile) String() string {
|
||||
return fmt.Sprintf("<ImageTile \"%v\">", it.fileName)
|
||||
}
|
@ -1,331 +0,0 @@
|
||||
// Copyright (c) 2019-2022 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/cheggaaa/pb/v3"
|
||||
)
|
||||
|
||||
var regexFileParse = regexp.MustCompile(`^(-?\d+),(-?\d+).png$`)
|
||||
|
||||
func loadImages(path string, scaleDivider int) ([]imageTile, error) {
|
||||
var imageTiles []imageTile
|
||||
|
||||
if scaleDivider < 1 {
|
||||
return nil, fmt.Errorf("invalid scale of %v", scaleDivider)
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(path, "*.png"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
baseName := filepath.Base(file)
|
||||
result := regexFileParse.FindStringSubmatch(baseName)
|
||||
var x, y int
|
||||
if parsed, err := strconv.ParseInt(result[1], 10, 0); err == nil {
|
||||
x = int(parsed)
|
||||
} else {
|
||||
return nil, fmt.Errorf("error parsing %v to integer: %w", result[1], err)
|
||||
}
|
||||
if parsed, err := strconv.ParseInt(result[2], 10, 0); err == nil {
|
||||
y = int(parsed)
|
||||
} else {
|
||||
return nil, fmt.Errorf("error parsing %v to integer: %w", result[2], err)
|
||||
}
|
||||
|
||||
width, height, err := getImageFileDimension(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
imageTiles = append(imageTiles, imageTile{
|
||||
fileName: file,
|
||||
scaleDivider: scaleDivider,
|
||||
image: image.Rect(x/scaleDivider, y/scaleDivider, (x+width)/scaleDivider, (y+height)/scaleDivider),
|
||||
imageMutex: &sync.RWMutex{},
|
||||
})
|
||||
}
|
||||
|
||||
return imageTiles, nil
|
||||
}
|
||||
|
||||
// Stitch takes a list of tiles and stitches them together.
|
||||
// The destImage shouldn't be too large, or it gets too slow.
|
||||
func Stitch(tiles []imageTile, destImage *image.RGBA) error {
|
||||
//intersectTiles := []*imageTile{}
|
||||
images := []*image.RGBA{}
|
||||
|
||||
// Get only the tiles that intersect with the destination image bounds.
|
||||
// Ignore alignment here, doesn't matter if an image overlaps a few pixels anyways.
|
||||
for i, tile := range tiles {
|
||||
if tile.OffsetBounds().Overlaps(destImage.Bounds()) {
|
||||
tilePtr := &tiles[i]
|
||||
img, err := tilePtr.GetImage()
|
||||
if err != nil {
|
||||
log.Printf("couldn't load image tile %s: %v", tile.String(), err)
|
||||
continue
|
||||
}
|
||||
//intersectTiles = append(intersectTiles, tilePtr)
|
||||
imgCopy := *img
|
||||
imgCopy.Rect = imgCopy.Rect.Add(tile.offset)
|
||||
images = append(images, &imgCopy)
|
||||
}
|
||||
}
|
||||
|
||||
//log.Printf("intersectTiles: %v", intersectTiles)
|
||||
|
||||
/*for _, intersectTile := range intersectTiles {
|
||||
intersectTile.loadImage()
|
||||
draw.Draw(destImage, destImage.Bounds(), intersectTile.image, destImage.Bounds().Min, draw.Over)
|
||||
}*/
|
||||
|
||||
/*for _, intersectTile := range intersectTiles {
|
||||
drawLabel(destImage, intersectTile.image.Bounds().Min.X, intersectTile.image.Bounds().Min.Y, fmt.Sprintf("%v", intersectTile.fileName))
|
||||
}*/
|
||||
|
||||
drawMedianBlended(images, destImage)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StitchGrid calls Stitch, but divides the workload into a grid of chunks.
|
||||
// Additionally it runs the workload multithreaded.
|
||||
func StitchGrid(tiles []imageTile, destImage *image.RGBA, gridSize int, bar *pb.ProgressBar) (errResult error) {
|
||||
//workloads := gridifyRectangle(destImage.Bounds(), gridSize)
|
||||
workloads, err := hilbertifyRectangle(destImage.Bounds(), gridSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if bar != nil {
|
||||
bar.SetTotal(int64(len(workloads))).Start()
|
||||
}
|
||||
|
||||
// Start worker threads
|
||||
wc := make(chan image.Rectangle)
|
||||
wg := sync.WaitGroup{}
|
||||
for i := 0; i < runtime.NumCPU()*2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for workload := range wc {
|
||||
if err := Stitch(tiles, destImage.SubImage(workload).(*image.RGBA)); err != nil {
|
||||
errResult = err // This will not stop execution, but at least one of any errors is returned.
|
||||
}
|
||||
if bar != nil {
|
||||
bar.Increment()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Push workload to worker threads
|
||||
for _, workload := range workloads {
|
||||
wc <- workload
|
||||
}
|
||||
|
||||
// Wait until all worker threads are done
|
||||
close(wc)
|
||||
wg.Wait()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func drawMedianBlended(images []*image.RGBA, destImage *image.RGBA) {
|
||||
bounds := destImage.Bounds()
|
||||
|
||||
// Create arrays to be reused every pixel
|
||||
rListEmpty, gListEmpty, bListEmpty := make([]int, 0, len(images)), make([]int, 0, len(images)), make([]int, 0, len(images))
|
||||
|
||||
for iy := bounds.Min.Y; iy < bounds.Max.Y; iy++ {
|
||||
for ix := bounds.Min.X; ix < bounds.Max.X; ix++ {
|
||||
rList, gList, bList := rListEmpty, gListEmpty, bListEmpty
|
||||
point := image.Point{ix, iy}
|
||||
found := false
|
||||
|
||||
// Iterate through all images and create a list of colors.
|
||||
for _, img := range images {
|
||||
if point.In(img.Bounds()) {
|
||||
col := img.RGBAAt(point.X, point.Y)
|
||||
rList, gList, bList = append(rList, int(col.R)), append(gList, int(col.G)), append(bList, int(col.B))
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
// If there were no images to get data from, ignore the pixel.
|
||||
if !found {
|
||||
//destImage.SetRGBA(ix, iy, color.RGBA{})
|
||||
continue
|
||||
}
|
||||
|
||||
// Sort colors.
|
||||
sort.Ints(rList)
|
||||
sort.Ints(gList)
|
||||
sort.Ints(bList)
|
||||
|
||||
// Take the middle element of each color.
|
||||
var r, g, b uint8
|
||||
if len(rList)%2 == 0 {
|
||||
// Even
|
||||
r = uint8((rList[len(rList)/2-1] + rList[len(rList)/2]) / 2)
|
||||
} else {
|
||||
// Odd
|
||||
r = uint8(rList[(len(rList)-1)/2])
|
||||
}
|
||||
if len(gList)%2 == 0 {
|
||||
// Even
|
||||
g = uint8((gList[len(gList)/2-1] + gList[len(gList)/2]) / 2)
|
||||
} else {
|
||||
// Odd
|
||||
g = uint8(gList[(len(gList)-1)/2])
|
||||
}
|
||||
if len(bList)%2 == 0 {
|
||||
// Even
|
||||
b = uint8((bList[len(bList)/2-1] + bList[len(bList)/2]) / 2)
|
||||
} else {
|
||||
// Odd
|
||||
b = uint8(bList[(len(bList)-1)/2])
|
||||
}
|
||||
|
||||
destImage.SetRGBA(ix, iy, color.RGBA{r, g, b, 255})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compare takes a list of tiles and compares them pixel by pixel.
|
||||
// The resulting pixel difference sum is stored in each tile.
|
||||
func Compare(tiles []imageTile, bounds image.Rectangle) error {
|
||||
intersectTiles := []*imageTile{}
|
||||
images := []*image.RGBA{}
|
||||
|
||||
// Get only the tiles that intersect with the bounds.
|
||||
// Ignore alignment here, doesn't matter if an image overlaps a few pixels anyways.
|
||||
for i, tile := range tiles {
|
||||
if tile.OffsetBounds().Overlaps(bounds) {
|
||||
tilePtr := &tiles[i]
|
||||
img, err := tilePtr.GetImage()
|
||||
if err != nil {
|
||||
log.Printf("Couldn't load image tile %s: %v", tile.String(), err)
|
||||
continue
|
||||
}
|
||||
intersectTiles = append(intersectTiles, tilePtr)
|
||||
imgCopy := *img
|
||||
imgCopy.Rect = imgCopy.Rect.Add(tile.offset)
|
||||
images = append(images, &imgCopy)
|
||||
}
|
||||
}
|
||||
|
||||
tempTilesEmpty := make([]*imageTile, 0, len(intersectTiles))
|
||||
|
||||
for iy := bounds.Min.Y; iy < bounds.Max.Y; iy++ {
|
||||
for ix := bounds.Min.X; ix < bounds.Max.X; ix++ {
|
||||
var rMin, rMax, gMin, gMax, bMin, bMax uint8
|
||||
point := image.Point{ix, iy}
|
||||
found := false
|
||||
tempTiles := tempTilesEmpty
|
||||
|
||||
// Iterate through all images and find min and max subpixel values.
|
||||
for i, img := range images {
|
||||
if point.In(img.Bounds()) {
|
||||
tempTiles = append(tempTiles, intersectTiles[i])
|
||||
col := img.RGBAAt(point.X, point.Y)
|
||||
if !found {
|
||||
found = true
|
||||
rMin, rMax, gMin, gMax, bMin, bMax = col.R, col.R, col.G, col.G, col.B, col.B
|
||||
} else {
|
||||
if rMin > col.R {
|
||||
rMin = col.R
|
||||
}
|
||||
if rMax < col.R {
|
||||
rMax = col.R
|
||||
}
|
||||
if gMin > col.G {
|
||||
gMin = col.G
|
||||
}
|
||||
if gMax < col.G {
|
||||
gMax = col.G
|
||||
}
|
||||
if bMin > col.B {
|
||||
bMin = col.B
|
||||
}
|
||||
if bMax < col.B {
|
||||
bMax = col.B
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there were no images to get data from, ignore the pixel.
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
// Write the error value back into the tiles (Only those that contain the point point)
|
||||
for _, tile := range tempTiles {
|
||||
tile.pixelErrorSum += uint64(rMax-rMin) + uint64(gMax-gMin) + uint64(bMax-bMin)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompareGrid calls Compare, but divides the workload into a grid of chunks.
|
||||
// Additionally it runs the workload multithreaded.
|
||||
func CompareGrid(tiles []imageTile, bounds image.Rectangle, gridSize int, bar *pb.ProgressBar) (errResult error) {
|
||||
//workloads := gridifyRectangle(destImage.Bounds(), gridSize)
|
||||
workloads, err := hilbertifyRectangle(bounds, gridSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if bar != nil {
|
||||
bar.SetTotal(int64(len(workloads))).Start()
|
||||
}
|
||||
|
||||
// Start worker threads
|
||||
wc := make(chan image.Rectangle)
|
||||
wg := sync.WaitGroup{}
|
||||
for i := 0; i < runtime.NumCPU()*2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for workload := range wc {
|
||||
if err := Compare(tiles, workload); err != nil {
|
||||
errResult = err // This will not stop execution, but at least one of any errors is returned.
|
||||
}
|
||||
if bar != nil {
|
||||
bar.Increment()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Push workload to worker threads
|
||||
for _, workload := range workloads {
|
||||
wc <- workload
|
||||
}
|
||||
|
||||
// Wait until all worker threads are done
|
||||
close(wc)
|
||||
wg.Wait()
|
||||
|
||||
return
|
||||
}
|
356
bin/stitch/main.go
Normal file
356
bin/stitch/main.go
Normal file
@ -0,0 +1,356 @@
|
||||
// Copyright (c) 2019-2024 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"image"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/1lann/promptui"
|
||||
"github.com/cheggaaa/pb/v3"
|
||||
)
|
||||
|
||||
var flagInputPath = flag.String("input", filepath.Join(".", "..", "..", "output"), "The source path of the image tiles to be stitched.")
|
||||
var flagEntitiesInputPath = flag.String("entities", filepath.Join(".", "..", "..", "output", "entities.json"), "The path to the entities.json file.")
|
||||
var flagPlayerPathInputPath = flag.String("player-path", filepath.Join(".", "..", "..", "output", "player-path.json"), "The path to the player-path.json file.")
|
||||
var flagOutputPath = flag.String("output", filepath.Join(".", "output.png"), "The path and filename of the resulting stitched image. Supported formats/file extensions: `.png`, `.webp`, `.jpg`, `.dzi`.")
|
||||
var flagScaleDivider = flag.Int("divide", 1, "A downscaling factor. 2 will produce an image with half the side lengths.")
|
||||
var flagBlendTileLimit = flag.Int("blend-tile-limit", 9, "Limits median blending to the n newest tiles by file modification time. If set to 0, all available tiles will be median blended.")
|
||||
var flagDZITileSize = flag.Int("dzi-tile-size", 512, "The size of the resulting deep zoom image (DZI) tiles in pixels.")
|
||||
var flagDZIOverlap = flag.Int("dzi-tile-overlap", 2, "The number of additional pixels around every deep zoom image (DZI) tile.")
|
||||
var flagWebPLevel = flag.Int("webp-level", 8, "Compression level of WebP files, from 0 (fast) to 9 (slow, best compression).")
|
||||
var flagXMin = flag.Int("xmin", 0, "Left bound of the output rectangle. This coordinate is included in the output.")
|
||||
var flagYMin = flag.Int("ymin", 0, "Upper bound of the output rectangle. This coordinate is included in the output.")
|
||||
var flagXMax = flag.Int("xmax", 0, "Right bound of the output rectangle. This coordinate is not included in the output.")
|
||||
var flagYMax = flag.Int("ymax", 0, "Lower bound of the output rectangle. This coordinate is not included in the output.")
|
||||
|
||||
func main() {
|
||||
log.Printf("Noita MapCapture stitching tool v%s.", version)
|
||||
|
||||
flag.Parse()
|
||||
|
||||
var overlays []StitchedImageOverlay
|
||||
|
||||
// Query the user, if there were no cmd arguments given.
|
||||
if flag.NFlag() == 0 {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Enter downscaling factor:",
|
||||
Default: fmt.Sprint(*flagScaleDivider),
|
||||
AllowEdit: true,
|
||||
Validate: func(s string) error {
|
||||
var num int
|
||||
_, err := fmt.Sscanf(s, "%d", &num)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int(num) < 1 {
|
||||
return fmt.Errorf("number must be larger than 0")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v.", err)
|
||||
}
|
||||
fmt.Sscanf(result, "%d", flagScaleDivider)
|
||||
}
|
||||
|
||||
// Query the user, if there were no cmd arguments given.
|
||||
if flag.NFlag() == 0 {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Enter blend tile limit:",
|
||||
Default: fmt.Sprint(*flagBlendTileLimit),
|
||||
AllowEdit: true,
|
||||
Validate: func(s string) error {
|
||||
var num int
|
||||
_, err := fmt.Sscanf(s, "%d", &num)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int(num) < 0 {
|
||||
return fmt.Errorf("number must be at least 0")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v.", err)
|
||||
}
|
||||
fmt.Sscanf(result, "%d", flagBlendTileLimit)
|
||||
}
|
||||
|
||||
// Query the user, if there were no cmd arguments given.
|
||||
if flag.NFlag() == 0 {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Enter input path:",
|
||||
Default: *flagInputPath,
|
||||
AllowEdit: true,
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v.", err)
|
||||
}
|
||||
*flagInputPath = result
|
||||
}
|
||||
|
||||
// Query the user, if there were no cmd arguments given.
|
||||
if flag.NFlag() == 0 {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Enter \"entities.json\" path:",
|
||||
Default: *flagEntitiesInputPath,
|
||||
AllowEdit: true,
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v.", err)
|
||||
}
|
||||
*flagEntitiesInputPath = result
|
||||
}
|
||||
|
||||
// Load entities if requested.
|
||||
entities, err := LoadEntities(*flagEntitiesInputPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to load entities: %v.", err)
|
||||
}
|
||||
if len(entities) > 0 {
|
||||
log.Printf("Got %v entities.", len(entities))
|
||||
overlays = append(overlays, entities) // Add entities to overlay drawing list.
|
||||
}
|
||||
|
||||
// Query the user, if there were no cmd arguments given.
|
||||
if flag.NFlag() == 0 {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Enter \"player-path.json\" path:",
|
||||
Default: *flagPlayerPathInputPath,
|
||||
AllowEdit: true,
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v.", err)
|
||||
}
|
||||
*flagPlayerPathInputPath = result
|
||||
}
|
||||
|
||||
// Load player path if requested.
|
||||
playerPath, err := LoadPlayerPath(*flagPlayerPathInputPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to load player path: %v.", err)
|
||||
}
|
||||
if len(playerPath) > 0 {
|
||||
log.Printf("Got %v player path entries.", len(playerPath))
|
||||
overlays = append(overlays, playerPath) // Add player path to overlay drawing list.
|
||||
}
|
||||
|
||||
log.Printf("Starting to read tile information at %q.", *flagInputPath)
|
||||
tiles, err := LoadImageTiles(*flagInputPath, *flagScaleDivider)
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
if len(tiles) == 0 {
|
||||
log.Panicf("Got no image tiles from %q.", *flagInputPath)
|
||||
}
|
||||
log.Printf("Got %v tiles.", len(tiles))
|
||||
|
||||
totalBounds := image.Rectangle{}
|
||||
for i, tile := range tiles {
|
||||
if i == 0 {
|
||||
totalBounds = tile.Bounds()
|
||||
} else {
|
||||
totalBounds = totalBounds.Union(tile.Bounds())
|
||||
}
|
||||
}
|
||||
log.Printf("Total size of the possible output space is %v.", totalBounds)
|
||||
|
||||
// If the output rect is empty, use the rectangle that encloses all tiles.
|
||||
outputRect := image.Rect(*flagXMin, *flagYMin, *flagXMax, *flagYMax)
|
||||
if outputRect.Empty() {
|
||||
outputRect = totalBounds
|
||||
}
|
||||
|
||||
// Query the user, if there were no cmd arguments given.
|
||||
if flag.NFlag() == 0 {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Enter output rectangle (xMin,yMin;xMax,yMax):",
|
||||
Default: fmt.Sprintf("%d,%d;%d,%d", outputRect.Min.X, outputRect.Min.Y, outputRect.Max.X, outputRect.Max.Y),
|
||||
AllowEdit: true,
|
||||
Validate: func(s string) error {
|
||||
var xMin, yMin, xMax, yMax int
|
||||
_, err := fmt.Sscanf(s, "%d,%d;%d,%d", &xMin, &yMin, &xMax, &yMax)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rect := image.Rect(xMin, yMin, xMax, yMax)
|
||||
if rect.Empty() {
|
||||
return fmt.Errorf("rectangle must not be empty")
|
||||
}
|
||||
|
||||
outputRect = rect
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v.", err)
|
||||
}
|
||||
var xMin, yMin, xMax, yMax int
|
||||
fmt.Sscanf(result, "%d,%d;%d,%d", &xMin, &yMin, &xMax, &yMax)
|
||||
outputRect = image.Rect(xMin, yMin, xMax, yMax)
|
||||
}
|
||||
|
||||
// Query the user, if there were no cmd arguments given.
|
||||
if flag.NFlag() == 0 {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Enter output filename and path:",
|
||||
Default: *flagOutputPath,
|
||||
AllowEdit: true,
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v.", err)
|
||||
}
|
||||
*flagOutputPath = result
|
||||
}
|
||||
|
||||
fileExtension := strings.ToLower(filepath.Ext(*flagOutputPath))
|
||||
|
||||
// Query the user, if there were no cmd arguments given.
|
||||
if flag.NFlag() == 0 && fileExtension == ".dzi" {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Enter DZI tile size:",
|
||||
Default: fmt.Sprint(*flagDZITileSize),
|
||||
AllowEdit: true,
|
||||
Validate: func(s string) error {
|
||||
var num int
|
||||
_, err := fmt.Sscanf(s, "%d", &num)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int(num) < 1 {
|
||||
return fmt.Errorf("number must be at least 1")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v.", err)
|
||||
}
|
||||
fmt.Sscanf(result, "%d", flagDZITileSize)
|
||||
}
|
||||
|
||||
// Query the user, if there were no cmd arguments given.
|
||||
if flag.NFlag() == 0 && fileExtension == ".dzi" {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Enter DZI tile overlap:",
|
||||
Default: fmt.Sprint(*flagDZIOverlap),
|
||||
AllowEdit: true,
|
||||
Validate: func(s string) error {
|
||||
var num int
|
||||
_, err := fmt.Sscanf(s, "%d", &num)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int(num) < 0 {
|
||||
return fmt.Errorf("number must be at least 0")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v.", err)
|
||||
}
|
||||
fmt.Sscanf(result, "%d", flagDZIOverlap)
|
||||
}
|
||||
|
||||
// Query the user, if there were no cmd arguments given.
|
||||
if flag.NFlag() == 0 && (fileExtension == ".dzi" || fileExtension == ".webp") {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Enter WebP compression level:",
|
||||
Default: fmt.Sprint(*flagWebPLevel),
|
||||
AllowEdit: true,
|
||||
Validate: func(s string) error {
|
||||
var num int
|
||||
_, err := fmt.Sscanf(s, "%d", &num)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int(num) < 0 {
|
||||
return fmt.Errorf("level must be at least 0")
|
||||
}
|
||||
if int(num) > 9 {
|
||||
return fmt.Errorf("level must not be larger than 9")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v.", err)
|
||||
}
|
||||
fmt.Sscanf(result, "%d", flagWebPLevel)
|
||||
}
|
||||
|
||||
blendMethod := BlendMethodMedian{
|
||||
BlendTileLimit: *flagBlendTileLimit, // Limit median blending to the n newest tiles by file modification time.
|
||||
}
|
||||
|
||||
stitchedImage, err := NewStitchedImage(tiles, outputRect, blendMethod, 128, overlays)
|
||||
if err != nil {
|
||||
log.Panicf("NewStitchedImage() failed: %v.", err)
|
||||
}
|
||||
|
||||
bar := pb.Full.New(0)
|
||||
|
||||
switch fileExtension {
|
||||
case ".png":
|
||||
if err := exportPNGStitchedImage(stitchedImage, *flagOutputPath, bar); err != nil {
|
||||
log.Panicf("Export of PNG file failed: %v", err)
|
||||
}
|
||||
case ".jpg", ".jpeg":
|
||||
if err := exportJPEGStitchedImage(stitchedImage, *flagOutputPath, bar); err != nil {
|
||||
log.Panicf("Export of JPEG file failed: %v", err)
|
||||
}
|
||||
case ".webp":
|
||||
if err := exportWebPStitchedImage(stitchedImage, *flagOutputPath, bar, *flagWebPLevel); err != nil {
|
||||
log.Panicf("Export of WebP file failed: %v", err)
|
||||
}
|
||||
case ".dzi":
|
||||
if err := exportDZIStitchedImage(stitchedImage, *flagOutputPath, bar, *flagDZITileSize, *flagDZIOverlap, *flagWebPLevel); err != nil {
|
||||
log.Panicf("Export of DZI file failed: %v", err)
|
||||
}
|
||||
default:
|
||||
log.Panicf("Unknown output format %q.", fileExtension)
|
||||
}
|
||||
|
||||
log.Printf("Created output in %v.", time.Since(bar.StartTime()))
|
||||
|
||||
//fmt.Println("Press the enter key to terminate the console screen!")
|
||||
//fmt.Scanln()
|
||||
}
|
@ -1,88 +0,0 @@
|
||||
// Copyright (c) 2019-2020 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
)
|
||||
|
||||
// MedianBlendedImageRowHeight defines the height of the cached output image.
|
||||
const MedianBlendedImageRowHeight = 256
|
||||
|
||||
// MedianBlendedImage combines several imageTile to a single RGBA image.
|
||||
type MedianBlendedImage struct {
|
||||
tiles []imageTile
|
||||
bounds image.Rectangle
|
||||
|
||||
cachedRow *image.RGBA
|
||||
queryCounter int
|
||||
}
|
||||
|
||||
// NewMedianBlendedImage creates a new image from several single image tiles.
|
||||
func NewMedianBlendedImage(tiles []imageTile, bounds image.Rectangle) *MedianBlendedImage {
|
||||
return &MedianBlendedImage{
|
||||
tiles: tiles,
|
||||
bounds: bounds,
|
||||
cachedRow: &image.RGBA{},
|
||||
}
|
||||
}
|
||||
|
||||
// ColorModel returns the Image's color model.
|
||||
func (mbi *MedianBlendedImage) ColorModel() color.Model {
|
||||
return color.RGBAModel
|
||||
}
|
||||
|
||||
// Bounds returns the domain for which At can return non-zero color.
|
||||
// The bounds do not necessarily contain the point (0, 0).
|
||||
func (mbi *MedianBlendedImage) Bounds() image.Rectangle {
|
||||
return mbi.bounds
|
||||
}
|
||||
|
||||
// At returns the color of the pixel at (x, y).
|
||||
// At(Bounds().Min.X, Bounds().Min.Y) returns the upper-left pixel of the grid.
|
||||
// At(Bounds().Max.X-1, Bounds().Max.Y-1) returns the lower-right one.
|
||||
func (mbi *MedianBlendedImage) At(x, y int) color.Color {
|
||||
p := image.Point{x, y}
|
||||
|
||||
// Assume that every pixel is only queried once
|
||||
mbi.queryCounter++
|
||||
|
||||
if !p.In(mbi.cachedRow.Bounds()) {
|
||||
// Need to create a new row image
|
||||
rect := mbi.Bounds()
|
||||
rect.Min.Y = divideFloor(y, MedianBlendedImageRowHeight) * MedianBlendedImageRowHeight
|
||||
rect.Max.Y = rect.Min.Y + MedianBlendedImageRowHeight
|
||||
|
||||
if !p.In(rect) {
|
||||
return color.RGBA{}
|
||||
}
|
||||
|
||||
mbi.cachedRow = image.NewRGBA(rect)
|
||||
|
||||
// TODO: Don't use hilbert curve here
|
||||
if err := StitchGrid(mbi.tiles, mbi.cachedRow, 512, nil); err != nil {
|
||||
return color.RGBA{}
|
||||
}
|
||||
}
|
||||
|
||||
return mbi.cachedRow.RGBAAt(x, y)
|
||||
}
|
||||
|
||||
// Opaque returns whether the image is fully opaque.
|
||||
//
|
||||
// For more speed and smaller filesizes, MedianBlendedImage will be marked as non-transparent.
|
||||
// This will speed up image saving by 2x, as there is no need to iterate over the whole image to find a single non opaque pixel.
|
||||
func (mbi *MedianBlendedImage) Opaque() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Progress returns the approximate progress of any process that scans the image from top to bottom.
|
||||
func (mbi *MedianBlendedImage) Progress() (value, max int) {
|
||||
size := mbi.Bounds().Size()
|
||||
|
||||
return mbi.queryCounter, size.X * size.Y
|
||||
}
|
101
bin/stitch/player-path.go
Normal file
101
bin/stitch/player-path.go
Normal file
@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2022 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
"os"
|
||||
|
||||
"github.com/tdewolff/canvas"
|
||||
"github.com/tdewolff/canvas/renderers/rasterizer"
|
||||
)
|
||||
|
||||
var playerPathDisplayStyle = canvas.Style{
|
||||
Fill: canvas.Paint{},
|
||||
//Stroke: canvas.Paint{Color: color.RGBA{0, 0, 0, 127}},
|
||||
StrokeWidth: 3.0,
|
||||
StrokeCapper: canvas.ButtCap,
|
||||
StrokeJoiner: canvas.MiterJoin,
|
||||
DashOffset: 0.0,
|
||||
Dashes: []float64{},
|
||||
FillRule: canvas.NonZero,
|
||||
}
|
||||
|
||||
type PlayerPathElement struct {
|
||||
From [2]float64 `json:"from"`
|
||||
To [2]float64 `json:"to"`
|
||||
HP float64 `json:"hp"`
|
||||
MaxHP float64 `json:"maxHP"`
|
||||
Polymorphed bool `json:"polymorphed"`
|
||||
}
|
||||
|
||||
type PlayerPath []PlayerPathElement
|
||||
|
||||
func LoadPlayerPath(path string) (PlayerPath, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result PlayerPath
|
||||
|
||||
jsonDec := json.NewDecoder(file)
|
||||
if err := jsonDec.Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Draw implements the StitchedImageOverlay interface.
|
||||
func (p PlayerPath) Draw(destImage *image.RGBA) {
|
||||
destRect := destImage.Bounds()
|
||||
|
||||
// Same as destImage, but top left is translated to (0, 0).
|
||||
originImage := destImage.SubImage(destRect).(*image.RGBA)
|
||||
originImage.Rect = originImage.Rect.Sub(destRect.Min)
|
||||
|
||||
c := canvas.New(float64(destRect.Dx()), float64(destRect.Dy()))
|
||||
ctx := canvas.NewContext(c)
|
||||
ctx.SetCoordSystem(canvas.CartesianIV)
|
||||
ctx.SetCoordRect(canvas.Rect{X: -float64(destRect.Min.X), Y: -float64(destRect.Min.Y), W: float64(destRect.Dx()), H: float64(destRect.Dy())}, float64(destRect.Dx()), float64(destRect.Dy()))
|
||||
|
||||
// Set drawing style.
|
||||
ctx.Style = playerPathDisplayStyle
|
||||
|
||||
for _, pathElement := range p {
|
||||
from, to := pathElement.From, pathElement.To
|
||||
|
||||
// Only draw if the path may cross the image rectangle.
|
||||
pathRect := image.Rectangle{image.Point{int(from[0]), int(from[1])}, image.Point{int(to[0]), int(to[1])}}.Canon().Inset(int(-playerPathDisplayStyle.StrokeWidth) - 1)
|
||||
if pathRect.Overlaps(destRect) {
|
||||
path := &canvas.Path{}
|
||||
path.MoveTo(from[0], from[1])
|
||||
path.LineTo(to[0], to[1])
|
||||
|
||||
if pathElement.Polymorphed {
|
||||
// Set stroke color to typically polymorph color.
|
||||
ctx.Style.Stroke.Color = color.RGBA{127, 50, 83, 127}
|
||||
} else {
|
||||
// Set stroke color depending on HP level.
|
||||
hpFactor := math.Max(math.Min(pathElement.HP/pathElement.MaxHP, 1), 0)
|
||||
hpFactorInv := 1 - hpFactor
|
||||
r, g, b, a := uint8((0*hpFactor+1*hpFactorInv)*127), uint8((1*hpFactor+0*hpFactorInv)*127), uint8(0), uint8(127)
|
||||
ctx.Style.Stroke.Color = color.RGBA{r, g, b, a}
|
||||
}
|
||||
|
||||
ctx.DrawPath(0, 0, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Theoretically we would need to linearize imgRGBA first, but DefaultColorSpace assumes that the color space is linear already.
|
||||
r := rasterizer.FromImage(originImage, canvas.DPMM(1.0), canvas.DefaultColorSpace)
|
||||
c.RenderTo(r)
|
||||
r.Close() // This just transforms the image's luminance curve back from linear into non linear.
|
||||
}
|
21
bin/stitch/profiling.go
Normal file
21
bin/stitch/profiling.go
Normal file
@ -0,0 +1,21 @@
|
||||
// Copyright (c) 2022 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "net/http/pprof"
|
||||
)
|
||||
|
||||
func init() {
|
||||
/*port := 1234
|
||||
|
||||
go func() {
|
||||
http.ListenAndServe(fmt.Sprintf(":%d", port), nil)
|
||||
}()
|
||||
log.Printf("Profiler web server listening on port %d. Visit http://localhost:%d/debug/pprof", port, port)
|
||||
log.Printf("To profile the next 10 seconds and view the profile interactively:\n go tool pprof -http :8080 http://localhost:%d/debug/pprof/profile?seconds=10", port)
|
||||
*/
|
||||
}
|
@ -1,284 +0,0 @@
|
||||
// Copyright (c) 2019-2022 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/1lann/promptui"
|
||||
"github.com/cheggaaa/pb/v3"
|
||||
)
|
||||
|
||||
var flagInputPath = flag.String("input", filepath.Join(".", "..", "..", "output"), "The source path of the image tiles to be stitched.")
|
||||
var flagOutputPath = flag.String("output", filepath.Join(".", "output.png"), "The path and filename of the resulting stitched image.")
|
||||
var flagScaleDivider = flag.Int("divide", 1, "A downscaling factor. 2 will produce an image with half the side lengths.")
|
||||
var flagXMin = flag.Int("xmin", 0, "Left bound of the output rectangle. This coordinate is included in the output.")
|
||||
var flagYMin = flag.Int("ymin", 0, "Upper bound of the output rectangle. This coordinate is included in the output.")
|
||||
var flagXMax = flag.Int("xmax", 0, "Right bound of the output rectangle. This coordinate is not included in the output.")
|
||||
var flagYMax = flag.Int("ymax", 0, "Lower bound of the output rectangle. This coordinate is not included in the output.")
|
||||
var flagPrerender = flag.Bool("prerender", false, "Pre renders the image in RAM before saving. Can speed things up if you have enough RAM.")
|
||||
var flagCleanupThreshold = flag.Float64("cleanup", 0, "Enable cleanup mode with the given threshold. This will DELETE images from the input folder, no stitching will be done in this mode. A good value to start with is 0.999, which deletes images where the sum of the min-max difference of each sub-pixel overlapping with other images is less than 99.9%% of the maximum possible sum of pixel differences.")
|
||||
|
||||
func main() {
|
||||
log.Printf("Noita MapCapture stitching tool v%s", version)
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// Query the user, if there were no cmd arguments given
|
||||
if flag.NFlag() == 0 {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Enter downscaling factor:",
|
||||
Default: fmt.Sprint(*flagScaleDivider),
|
||||
AllowEdit: true,
|
||||
Validate: func(s string) error {
|
||||
var num int
|
||||
_, err := fmt.Sscanf(s, "%d", &num)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int(num) < 1 {
|
||||
return fmt.Errorf("number must be larger than 0")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v", err)
|
||||
}
|
||||
fmt.Sscanf(result, "%d", flagScaleDivider)
|
||||
}
|
||||
|
||||
// Query the user, if there were no cmd arguments given
|
||||
if flag.NFlag() == 0 {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Enter input path:",
|
||||
Default: *flagInputPath,
|
||||
AllowEdit: true,
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v", err)
|
||||
}
|
||||
*flagInputPath = result
|
||||
}
|
||||
|
||||
log.Printf("Starting to read tile information at \"%v\"", *flagInputPath)
|
||||
tiles, err := loadImages(*flagInputPath, *flagScaleDivider)
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
if len(tiles) == 0 {
|
||||
log.Panicf("Got no tiles inside of %v", *flagInputPath)
|
||||
}
|
||||
log.Printf("Got %v tiles", len(tiles))
|
||||
|
||||
totalBounds := image.Rectangle{}
|
||||
for i, tile := range tiles {
|
||||
if i == 0 {
|
||||
totalBounds = tile.Bounds()
|
||||
} else {
|
||||
totalBounds = totalBounds.Union(tile.Bounds())
|
||||
}
|
||||
}
|
||||
log.Printf("Total size of the possible output space is %v", totalBounds)
|
||||
|
||||
/*profFile, err := os.Create("cpu.prof")
|
||||
if err != nil {
|
||||
log.Panicf("could not create CPU profile: %v", err)
|
||||
}
|
||||
defer profFile.Close()
|
||||
if err := pprof.StartCPUProfile(profFile); err != nil {
|
||||
log.Panicf("could not start CPU profile: %v", err)
|
||||
}
|
||||
defer pprof.StopCPUProfile()*/
|
||||
|
||||
// If the output rect is empty, use the rectangle that encloses all tiles
|
||||
outputRect := image.Rect(*flagXMin, *flagYMin, *flagXMax, *flagYMax)
|
||||
if outputRect.Empty() {
|
||||
outputRect = totalBounds
|
||||
}
|
||||
|
||||
// Query the user, if there were no cmd arguments given
|
||||
if flag.NFlag() == 0 {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Enter output rectangle (xMin,yMin;xMax,yMax):",
|
||||
Default: fmt.Sprintf("%d,%d;%d,%d", outputRect.Min.X, outputRect.Min.Y, outputRect.Max.X, outputRect.Max.Y),
|
||||
AllowEdit: true,
|
||||
Validate: func(s string) error {
|
||||
var xMin, yMin, xMax, yMax int
|
||||
_, err := fmt.Sscanf(s, "%d,%d;%d,%d", &xMin, &yMin, &xMax, &yMax)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rect := image.Rect(xMin, yMin, xMax, yMax)
|
||||
if rect.Empty() {
|
||||
return fmt.Errorf("rectangle must not be empty")
|
||||
}
|
||||
|
||||
outputRect = rect
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v", err)
|
||||
}
|
||||
var xMin, yMin, xMax, yMax int
|
||||
fmt.Sscanf(result, "%d,%d;%d,%d", &xMin, &yMin, &xMax, &yMax)
|
||||
outputRect = image.Rect(xMin, yMin, xMax, yMax)
|
||||
}
|
||||
|
||||
// Query the user, if there were no cmd arguments given
|
||||
/*if flag.NFlag() == 0 {
|
||||
fmt.Println("\nYou can now define a cleanup threshold. This mode will DELETE input images based on their similarity with other overlapping input images. The range is from 0, where no images are deleted, to 1 where all images will be deleted. A good value to get rid of most artifacts is 0.999. If you enter a threshold above 0, the program will not stitch, but DELETE some of your input images. If you want to stitch, enter 0.")
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Enter cleanup threshold:",
|
||||
Default: strconv.FormatFloat(*flagCleanupThreshold, 'f', -1, 64),
|
||||
AllowEdit: true,
|
||||
Validate: func(s string) error {
|
||||
result, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result < 0 || result > 1 {
|
||||
return fmt.Errorf("Number %v outside of valid range [0;1]", result)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v", err)
|
||||
}
|
||||
*flagCleanupThreshold, err = strconv.ParseFloat(result, 64)
|
||||
if err != nil {
|
||||
log.Panicf("Error while parsing user input: %v", err)
|
||||
}
|
||||
}*/
|
||||
|
||||
if *flagCleanupThreshold < 0 || *flagCleanupThreshold > 1 {
|
||||
log.Panicf("Cleanup threshold (%v) outside of valid range [0;1]", *flagCleanupThreshold)
|
||||
}
|
||||
if *flagCleanupThreshold > 0 {
|
||||
bar := pb.Full.New(0)
|
||||
|
||||
log.Printf("Cleaning up %v tiles at %v", len(tiles), outputRect)
|
||||
if err := CompareGrid(tiles, outputRect, 512, bar); err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
bar.Finish()
|
||||
|
||||
for _, tile := range tiles {
|
||||
pixelErrorSumNormalized := float64(tile.pixelErrorSum) / float64(tile.Bounds().Size().X*tile.Bounds().Size().Y*3*255)
|
||||
if 1-pixelErrorSumNormalized <= *flagCleanupThreshold {
|
||||
os.Remove(tile.fileName)
|
||||
log.Printf("Tile %v has matching factor of %f. Deleted file!", &tile, 1-pixelErrorSumNormalized)
|
||||
} else {
|
||||
log.Printf("Tile %v has matching factor of %f", &tile, 1-pixelErrorSumNormalized)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Query the user, if there were no cmd arguments given
|
||||
if flag.NFlag() == 0 {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Enter output filename and path:",
|
||||
Default: *flagOutputPath,
|
||||
AllowEdit: true,
|
||||
}
|
||||
|
||||
result, err := prompt.Run()
|
||||
if err != nil {
|
||||
log.Panicf("Error while getting user input: %v", err)
|
||||
}
|
||||
*flagOutputPath = result
|
||||
}
|
||||
|
||||
var outputImage image.Image
|
||||
bar := pb.Full.New(0)
|
||||
var wg sync.WaitGroup
|
||||
done := make(chan bool)
|
||||
|
||||
if *flagPrerender {
|
||||
log.Printf("Creating output image with a size of %v", outputRect.Size())
|
||||
tempImage := image.NewRGBA(outputRect)
|
||||
|
||||
log.Printf("Stitching %v tiles into an image at %v", len(tiles), tempImage.Bounds())
|
||||
if err := StitchGrid(tiles, tempImage, 512, bar); err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
bar.Finish()
|
||||
|
||||
outputImage = tempImage
|
||||
} else {
|
||||
tempImage := NewMedianBlendedImage(tiles, outputRect)
|
||||
_, max := tempImage.Progress()
|
||||
bar.SetTotal(int64(max)).Start().SetRefreshRate(1 * time.Second)
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
value, _ := tempImage.Progress()
|
||||
bar.SetCurrent(int64(value))
|
||||
bar.Finish()
|
||||
return
|
||||
case <-ticker.C:
|
||||
value, _ := tempImage.Progress()
|
||||
bar.SetCurrent(int64(value))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
outputImage = tempImage
|
||||
}
|
||||
|
||||
log.Printf("Creating output file \"%v\"", *flagOutputPath)
|
||||
f, err := os.Create(*flagOutputPath)
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
if err := png.Encode(f, outputImage); err != nil {
|
||||
f.Close()
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
if !*flagPrerender {
|
||||
done <- true
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
if err := f.Close(); err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
log.Printf("Created output file \"%v\"", *flagOutputPath)
|
||||
|
||||
}
|
156
bin/stitch/stitched-image-cache.go
Normal file
156
bin/stitch/stitched-image-cache.go
Normal file
@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2022-2024 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"runtime"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// StitchedImageCache contains part of the actual image data of a stitched image.
|
||||
// This can be regenerated or invalidated at will.
|
||||
type StitchedImageCache struct {
|
||||
sync.Mutex
|
||||
|
||||
stitchedImage *StitchedImage // The parent object.
|
||||
|
||||
rect image.Rectangle // Position and size of the cached area.
|
||||
image *image.RGBA // Cached RGBA image. The bounds of this image are determined by the filename.
|
||||
|
||||
idleCounter byte // Is incremented when this cache object idles, and reset every time the cache is used.
|
||||
}
|
||||
|
||||
func NewStitchedImageCache(stitchedImage *StitchedImage, rect image.Rectangle) StitchedImageCache {
|
||||
return StitchedImageCache{
|
||||
stitchedImage: stitchedImage,
|
||||
rect: rect,
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateAuto invalidates this cache object when it had idled for too long.
|
||||
// The cache will be invalidated after `threshold + 1` calls to InvalidateAuto.
|
||||
func (sic *StitchedImageCache) InvalidateAuto(threshold byte) {
|
||||
sic.Lock()
|
||||
defer sic.Unlock()
|
||||
|
||||
if sic.image != nil {
|
||||
if sic.idleCounter >= threshold {
|
||||
sic.image = nil
|
||||
return
|
||||
}
|
||||
sic.idleCounter++
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate clears the cached image.
|
||||
func (sic *StitchedImageCache) Invalidate() {
|
||||
sic.Lock()
|
||||
defer sic.Unlock()
|
||||
sic.image = nil
|
||||
}
|
||||
|
||||
// Regenerate refills the cache image with valid image data.
|
||||
// This will block until there is a valid image, and it will *always* return a valid image.
|
||||
func (sic *StitchedImageCache) Regenerate() *image.RGBA {
|
||||
sic.Lock()
|
||||
defer sic.Unlock()
|
||||
|
||||
sic.idleCounter = 0
|
||||
|
||||
// Check if there is already a cache image.
|
||||
if sic.image != nil {
|
||||
return sic.image
|
||||
}
|
||||
|
||||
si := sic.stitchedImage
|
||||
|
||||
// Create new image with default background color.
|
||||
cacheImage := image.NewRGBA(sic.rect)
|
||||
draw.Draw(cacheImage, cacheImage.Bounds(), &image.Uniform{colorBackground}, cacheImage.Bounds().Min, draw.Src)
|
||||
|
||||
// List of tiles that intersect with the to be generated cache image.
|
||||
intersectingTiles := []*ImageTile{}
|
||||
for i := range si.tiles {
|
||||
tile := &si.tiles[i]
|
||||
if tile.Bounds().Overlaps(sic.rect) {
|
||||
intersectingTiles = append(intersectingTiles, tile)
|
||||
}
|
||||
}
|
||||
|
||||
// Start worker threads.
|
||||
workerQueue := make(chan image.Rectangle)
|
||||
waitGroup := sync.WaitGroup{}
|
||||
workers := (runtime.NumCPU() + 1) / 2
|
||||
for i := 0; i < workers; i++ {
|
||||
waitGroup.Add(1)
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
for workload := range workerQueue {
|
||||
// List of tiles that intersect with the workload chunk.
|
||||
workloadTiles := []*ImageTile{}
|
||||
|
||||
// Get only the tiles that intersect with the workload bounds.
|
||||
for _, tile := range intersectingTiles {
|
||||
if tile.Bounds().Overlaps(workload) {
|
||||
workloadTiles = append(workloadTiles, tile)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw blended tiles into cache image.
|
||||
// Restricted by the workload rectangle.
|
||||
si.blendMethod.Draw(workloadTiles, cacheImage.SubImage(workload).(*image.RGBA))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Divide rect into chunks and push to workers.
|
||||
for _, chunk := range GridifyRectangle(sic.rect, StitchedImageCacheGridSize) {
|
||||
workerQueue <- chunk
|
||||
}
|
||||
close(workerQueue)
|
||||
|
||||
// Wait until all worker threads are done.
|
||||
waitGroup.Wait()
|
||||
|
||||
// Draw overlays.
|
||||
for _, overlay := range si.overlays {
|
||||
if overlay != nil {
|
||||
overlay.Draw(cacheImage)
|
||||
}
|
||||
}
|
||||
|
||||
// Update cached image.
|
||||
sic.image = cacheImage
|
||||
return cacheImage
|
||||
}
|
||||
|
||||
// Returns the pixel color at x and y.
|
||||
func (sic *StitchedImageCache) RGBAAt(x, y int) color.RGBA {
|
||||
// Fast path: The image is loaded.
|
||||
sic.Lock()
|
||||
if sic.image != nil {
|
||||
defer sic.Unlock()
|
||||
sic.idleCounter = 0
|
||||
return sic.image.RGBAAt(x, y)
|
||||
}
|
||||
sic.Unlock()
|
||||
|
||||
// Slow path: The image data needs to be generated first.
|
||||
// This will block until the cache is regenerated.
|
||||
return sic.Regenerate().RGBAAt(x, y)
|
||||
}
|
||||
|
||||
// Returns the pixel color at x and y.
|
||||
func (sic *StitchedImageCache) At(x, y int) color.Color {
|
||||
return sic.RGBAAt(x, y)
|
||||
}
|
||||
|
||||
func (sic *StitchedImageCache) Bounds() image.Rectangle {
|
||||
return sic.rect
|
||||
}
|
166
bin/stitch/stitched-image.go
Normal file
166
bin/stitch/stitched-image.go
Normal file
@ -0,0 +1,166 @@
|
||||
// Copyright (c) 2022-2024 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The default background color.
|
||||
// We use a non transparent black.
|
||||
var colorBackground = color.RGBA{0, 0, 0, 255}
|
||||
|
||||
// StitchedImageCacheGridSize defines the worker chunk size when the cache image is regenerated.
|
||||
var StitchedImageCacheGridSize = 256
|
||||
|
||||
// StitchedImageBlendMethod defines how tiles are blended together.
|
||||
type StitchedImageBlendMethod interface {
|
||||
Draw(tiles []*ImageTile, destImage *image.RGBA) // Draw is called when a new cache image is generated.
|
||||
}
|
||||
|
||||
// StitchedImageOverlay defines an interface for arbitrary overlays that can be drawn over the stitched image.
|
||||
type StitchedImageOverlay interface {
|
||||
Draw(*image.RGBA)
|
||||
}
|
||||
|
||||
// StitchedImage combines several ImageTile objects into a single RGBA image.
|
||||
// The way the images are combined/blended is defined by the blendFunc.
|
||||
type StitchedImage struct {
|
||||
tiles ImageTiles
|
||||
bounds image.Rectangle
|
||||
blendMethod StitchedImageBlendMethod
|
||||
overlays []StitchedImageOverlay
|
||||
|
||||
cacheRowHeight int
|
||||
cacheRows []StitchedImageCache
|
||||
cacheRowYOffset int // Defines the pixel offset of the first cache row.
|
||||
|
||||
oldCacheRowIndex int
|
||||
queryCounter atomic.Int64
|
||||
}
|
||||
|
||||
// NewStitchedImage creates a new image from several single image tiles.
|
||||
func NewStitchedImage(tiles ImageTiles, bounds image.Rectangle, blendMethod StitchedImageBlendMethod, cacheRowHeight int, overlays []StitchedImageOverlay) (*StitchedImage, error) {
|
||||
if bounds.Empty() {
|
||||
return nil, fmt.Errorf("given boundaries are empty")
|
||||
}
|
||||
if blendMethod == nil {
|
||||
return nil, fmt.Errorf("no blending method given")
|
||||
}
|
||||
if cacheRowHeight <= 0 {
|
||||
return nil, fmt.Errorf("invalid cache row height of %d pixels", cacheRowHeight)
|
||||
}
|
||||
|
||||
stitchedImage := &StitchedImage{
|
||||
tiles: tiles,
|
||||
bounds: bounds,
|
||||
blendMethod: blendMethod,
|
||||
overlays: overlays,
|
||||
}
|
||||
|
||||
// Generate cache image rows.
|
||||
maxRow := (bounds.Dy() - 1) / cacheRowHeight
|
||||
var cacheRows []StitchedImageCache
|
||||
for i := 0; i <= maxRow; i++ {
|
||||
rect := image.Rect(bounds.Min.X, bounds.Min.Y+i*cacheRowHeight, bounds.Max.X, bounds.Min.Y+(i+1)*cacheRowHeight)
|
||||
cacheRows = append(cacheRows, NewStitchedImageCache(stitchedImage, rect.Intersect(bounds)))
|
||||
}
|
||||
stitchedImage.cacheRowHeight = cacheRowHeight
|
||||
stitchedImage.cacheRowYOffset = -bounds.Min.Y
|
||||
stitchedImage.cacheRows = cacheRows
|
||||
|
||||
// Start ticker to automatically invalidate caches.
|
||||
// Due to this, the stitchedImage object is not composable, as this goroutine will always have a reference.
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
for range ticker.C {
|
||||
for rowIndex := range stitchedImage.cacheRows {
|
||||
stitchedImage.cacheRows[rowIndex].InvalidateAuto(3) // Invalidate cache row after 3 seconds of being idle.
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return stitchedImage, nil
|
||||
}
|
||||
|
||||
// ColorModel returns the Image's color model.
|
||||
func (si *StitchedImage) ColorModel() color.Model {
|
||||
return color.RGBAModel
|
||||
}
|
||||
|
||||
// Bounds returns the domain for which At can return non-zero color.
|
||||
// The bounds do not necessarily contain the point (0, 0).
|
||||
func (si *StitchedImage) Bounds() image.Rectangle {
|
||||
return si.bounds
|
||||
}
|
||||
|
||||
func (si *StitchedImage) At(x, y int) color.Color {
|
||||
return si.RGBAAt(x, y)
|
||||
}
|
||||
|
||||
// At returns the color of the pixel at (x, y).
|
||||
//
|
||||
// This is optimized to be read line by line (scanning), it will be much slower with random access.
|
||||
//
|
||||
// For the `Progress()` method to work correctly, every pixel should be queried exactly once.
|
||||
//
|
||||
// At(Bounds().Min.X, Bounds().Min.Y) // returns the top-left pixel of the image.
|
||||
// At(Bounds().Max.X-1, Bounds().Max.Y-1) // returns the bottom-right pixel.
|
||||
func (si *StitchedImage) RGBAAt(x, y int) color.RGBA {
|
||||
// Assume that every pixel is only queried once.
|
||||
si.queryCounter.Add(1)
|
||||
|
||||
// Determine the cache rowIndex index.
|
||||
rowIndex := (y + si.cacheRowYOffset) / si.cacheRowHeight
|
||||
if rowIndex < 0 || rowIndex >= len(si.cacheRows) {
|
||||
return colorBackground
|
||||
}
|
||||
|
||||
// Check if we advanced/changed the row index.
|
||||
// This doesn't happen a lot, so stuff inside this can be a bit more expensive.
|
||||
if si.oldCacheRowIndex != rowIndex {
|
||||
// Pre generate the new row asynchronously.
|
||||
newRowIndex := rowIndex + 1
|
||||
if newRowIndex >= 0 && newRowIndex < len(si.cacheRows) {
|
||||
go si.cacheRows[newRowIndex].Regenerate()
|
||||
}
|
||||
|
||||
// Invalidate all tiles that are above the next row.
|
||||
si.tiles.InvalidateAboveY((rowIndex+1)*si.cacheRowHeight - si.cacheRowYOffset)
|
||||
|
||||
si.oldCacheRowIndex = rowIndex
|
||||
}
|
||||
|
||||
return si.cacheRows[rowIndex].RGBAAt(x, y)
|
||||
}
|
||||
|
||||
// Opaque returns whether the image is fully opaque.
|
||||
//
|
||||
// For more speed and smaller file size, StitchedImage will be marked as non-transparent.
|
||||
// This will speed up image saving by 2x, as there is no need to iterate over the whole image just to find a single non opaque pixel.
|
||||
func (si *StitchedImage) Opaque() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Progress returns the approximate progress of any process that scans the image from top to bottom.
|
||||
func (si *StitchedImage) Progress() (value, max int) {
|
||||
size := si.Bounds().Size()
|
||||
|
||||
return int(si.queryCounter.Load()), size.X * size.Y
|
||||
}
|
||||
|
||||
// SubStitchedImage returns an image representing the portion of the image p visible through r.
|
||||
// The returned image references to the original stitched image, and therefore reuses its cache.
|
||||
func (si *StitchedImage) SubStitchedImage(r image.Rectangle) SubStitchedImage {
|
||||
return SubStitchedImage{
|
||||
StitchedImage: si,
|
||||
bounds: si.Bounds().Intersect(r),
|
||||
}
|
||||
}
|
36
bin/stitch/sub-stitched-image.go
Normal file
36
bin/stitch/sub-stitched-image.go
Normal file
@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2023-2024 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
)
|
||||
|
||||
type SubStitchedImage struct {
|
||||
*StitchedImage // The original stitched image.
|
||||
|
||||
bounds image.Rectangle // The new bounds of the cropped image.
|
||||
}
|
||||
|
||||
// Bounds returns the domain for which At can return non-zero color.
|
||||
// The bounds do not necessarily contain the point (0, 0).
|
||||
func (s SubStitchedImage) Bounds() image.Rectangle {
|
||||
return s.bounds
|
||||
}
|
||||
|
||||
func (s SubStitchedImage) At(x, y int) color.Color {
|
||||
return s.RGBAAt(x, y)
|
||||
}
|
||||
|
||||
func (s SubStitchedImage) RGBAAt(x, y int) color.RGBA {
|
||||
point := image.Point{X: x, Y: y}
|
||||
if !point.In(s.bounds) {
|
||||
return colorBackground
|
||||
}
|
||||
|
||||
return s.StitchedImage.RGBAAt(x, y)
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
// Copyright (c) 2019-2022 David Vogel
|
||||
// Copyright (c) 2019-2024 David Vogel
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
@ -8,20 +8,46 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
"golang.org/x/image/math/fixed"
|
||||
|
||||
"github.com/google/hilbert"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// QuickSelect returns the kth smallest element of the given unsorted list.
|
||||
// This is faster than sorting the list and then selecting the wanted element.
|
||||
//
|
||||
// Source: https://rosettacode.org/wiki/Quickselect_algorithm#Go
|
||||
func QuickSelectUInt8(list []uint8, k int) uint8 {
|
||||
for {
|
||||
// Partition.
|
||||
px := len(list) / 2
|
||||
pv := list[px]
|
||||
last := len(list) - 1
|
||||
list[px], list[last] = list[last], list[px]
|
||||
|
||||
i := 0
|
||||
for j := 0; j < last; j++ {
|
||||
if list[j] < pv {
|
||||
list[i], list[j] = list[j], list[i]
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// Select.
|
||||
if i == k {
|
||||
return pv
|
||||
}
|
||||
if k < i {
|
||||
list = list[:i]
|
||||
} else {
|
||||
list[i], list[last] = list[last], list[i]
|
||||
list = list[i+1:]
|
||||
k -= i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Source: https://gist.github.com/sergiotapia/7882944
|
||||
func getImageFileDimension(imagePath string) (int, int, error) {
|
||||
func GetImageFileDimension(imagePath string) (int, int, error) {
|
||||
file, err := os.Open(imagePath)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("can't open file %v: %w", imagePath, err)
|
||||
@ -36,41 +62,13 @@ func getImageFileDimension(imagePath string) (int, int, error) {
|
||||
return image.Width, image.Height, nil
|
||||
}
|
||||
|
||||
// getImageDifferenceValue returns the average quadratic difference of the (sub)pixels.
|
||||
// 0 means the images are identical, +inf means that the images don't intersect.
|
||||
func getImageDifferenceValue(a, b *image.RGBA, offsetA image.Point) float64 {
|
||||
intersection := a.Bounds().Add(offsetA).Intersect(b.Bounds())
|
||||
|
||||
if intersection.Empty() {
|
||||
return math.Inf(1)
|
||||
}
|
||||
|
||||
aSub := a.SubImage(intersection.Sub(offsetA)).(*image.RGBA)
|
||||
bSub := b.SubImage(intersection).(*image.RGBA)
|
||||
|
||||
intersectionWidth := intersection.Dx() * 4
|
||||
intersectionHeight := intersection.Dy()
|
||||
|
||||
var value int64
|
||||
|
||||
for iy := 0; iy < intersectionHeight; iy++ {
|
||||
aSlice := aSub.Pix[iy*aSub.Stride : iy*aSub.Stride+intersectionWidth]
|
||||
bSlice := bSub.Pix[iy*bSub.Stride : iy*bSub.Stride+intersectionWidth]
|
||||
for ix := 0; ix < intersectionWidth; ix += 3 {
|
||||
diff := int64(aSlice[ix]) - int64(bSlice[ix])
|
||||
value += diff * diff
|
||||
}
|
||||
}
|
||||
|
||||
return float64(value) / float64(intersectionWidth*intersectionHeight)
|
||||
}
|
||||
|
||||
func gridifyRectangle(rect image.Rectangle, gridSize int) (result []image.Rectangle) {
|
||||
for y := divideFloor(rect.Min.Y, gridSize); y < divideCeil(rect.Max.Y, gridSize); y++ {
|
||||
for x := divideFloor(rect.Min.X, gridSize); x < divideCeil(rect.Max.X, gridSize); x++ {
|
||||
func GridifyRectangle(rect image.Rectangle, gridSize int) (result []image.Rectangle) {
|
||||
for y := DivideFloor(rect.Min.Y, gridSize); y <= DivideCeil(rect.Max.Y-1, gridSize); y++ {
|
||||
for x := DivideFloor(rect.Min.X, gridSize); x <= DivideCeil(rect.Max.X-1, gridSize); x++ {
|
||||
tempRect := image.Rect(x*gridSize, y*gridSize, (x+1)*gridSize, (y+1)*gridSize)
|
||||
if tempRect.Overlaps(rect) {
|
||||
result = append(result, tempRect)
|
||||
intersection := tempRect.Intersect(rect)
|
||||
if !intersection.Empty() {
|
||||
result = append(result, intersection)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -78,63 +76,8 @@ func gridifyRectangle(rect image.Rectangle, gridSize int) (result []image.Rectan
|
||||
return
|
||||
}
|
||||
|
||||
func hilbertifyRectangle(rect image.Rectangle, gridSize int) ([]image.Rectangle, error) {
|
||||
grid := gridifyRectangle(rect, gridSize)
|
||||
|
||||
gridX := divideFloor(rect.Min.X, gridSize)
|
||||
gridY := divideFloor(rect.Min.Y, gridSize)
|
||||
|
||||
// Size of the grid in chunks
|
||||
gridWidth := divideCeil(rect.Max.X, gridSize) - divideFloor(rect.Min.X, gridSize)
|
||||
gridHeight := divideCeil(rect.Max.Y, gridSize) - divideFloor(rect.Min.Y, gridSize)
|
||||
|
||||
s, err := hilbert.NewHilbert(int(math.Pow(2, math.Ceil(math.Log2(math.Max(float64(gridWidth), float64(gridHeight)))))))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(grid, func(i, j int) bool {
|
||||
// Ignore out of range errors, as they shouldn't happen.
|
||||
hilbertIndexA, _ := s.MapInverse(grid[i].Min.X/gridSize-gridX, grid[i].Min.Y/gridSize-gridY)
|
||||
hilbertIndexB, _ := s.MapInverse(grid[j].Min.X/gridSize-gridX, grid[j].Min.Y/gridSize-gridY)
|
||||
return hilbertIndexA < hilbertIndexB
|
||||
})
|
||||
|
||||
return grid, nil
|
||||
}
|
||||
|
||||
func drawLabel(img *image.RGBA, x, y int, label string) {
|
||||
col := color.RGBA{200, 100, 0, 255}
|
||||
point := fixed.Point26_6{X: fixed.Int26_6(x * 64), Y: fixed.Int26_6(y * 64)}
|
||||
|
||||
d := &font.Drawer{
|
||||
Dst: img,
|
||||
Src: image.NewUniform(col),
|
||||
Face: basicfont.Face7x13,
|
||||
Dot: point,
|
||||
}
|
||||
d.DrawString(label)
|
||||
}
|
||||
|
||||
func intAbs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func pointAbs(p image.Point) image.Point {
|
||||
if p.X < 0 {
|
||||
p.X = -p.X
|
||||
}
|
||||
if p.Y < 0 {
|
||||
p.Y = -p.Y
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Integer division that rounds to the next integer towards negative infinity.
|
||||
func divideFloor(a, b int) int {
|
||||
func DivideFloor(a, b int) int {
|
||||
temp := a / b
|
||||
|
||||
if ((a ^ b) < 0) && (a%b != 0) {
|
||||
@ -145,7 +88,7 @@ func divideFloor(a, b int) int {
|
||||
}
|
||||
|
||||
// Integer division that rounds to the next integer towards positive infinity.
|
||||
func divideCeil(a, b int) int {
|
||||
func DivideCeil(a, b int) int {
|
||||
temp := a / b
|
||||
|
||||
if ((a ^ b) >= 0) && (a%b != 0) {
|
||||
@ -155,9 +98,43 @@ func divideCeil(a, b int) int {
|
||||
return temp
|
||||
}
|
||||
|
||||
func maxInt(x, y int) int {
|
||||
if x > y {
|
||||
return x
|
||||
}
|
||||
return y
|
||||
// https://gist.github.com/cstockton/d611ced26bb6b4d3f7d4237abb8613c4
|
||||
type LimitGroup struct {
|
||||
wg sync.WaitGroup
|
||||
mu *sync.Mutex
|
||||
c *sync.Cond
|
||||
l, n int
|
||||
}
|
||||
|
||||
func NewLimitGroup(n int) *LimitGroup {
|
||||
mu := new(sync.Mutex)
|
||||
return &LimitGroup{
|
||||
mu: mu,
|
||||
c: sync.NewCond(mu),
|
||||
l: n,
|
||||
n: n,
|
||||
}
|
||||
}
|
||||
|
||||
func (lg *LimitGroup) Add(delta int) {
|
||||
lg.mu.Lock()
|
||||
defer lg.mu.Unlock()
|
||||
if delta > lg.l {
|
||||
panic(`LimitGroup: delta must not exceed limit`)
|
||||
}
|
||||
for lg.n < 1 {
|
||||
lg.c.Wait()
|
||||
}
|
||||
lg.n -= delta
|
||||
lg.wg.Add(delta)
|
||||
}
|
||||
|
||||
func (lg *LimitGroup) Done() {
|
||||
lg.mu.Lock()
|
||||
defer lg.mu.Unlock()
|
||||
lg.n++
|
||||
lg.c.Signal()
|
||||
lg.wg.Done()
|
||||
}
|
||||
|
||||
func (lg *LimitGroup) Wait() { lg.wg.Wait() }
|
||||
|
@ -1,4 +1,4 @@
|
||||
-- Copyright (c) 2019-2022 David Vogel
|
||||
-- Copyright (c) 2019-2024 David Vogel
|
||||
--
|
||||
-- This software is released under the MIT License.
|
||||
-- https://opensource.org/licenses/MIT
|
||||
@ -28,10 +28,11 @@ local Vec2 = require("noita-api.vec2")
|
||||
|
||||
Capture.MapCapturingCtx = Capture.MapCapturingCtx or ProcessRunner.New()
|
||||
Capture.EntityCapturingCtx = Capture.EntityCapturingCtx or ProcessRunner.New()
|
||||
Capture.PlayerPathCapturingCtx = Capture.PlayerPathCapturingCtx or ProcessRunner.New()
|
||||
|
||||
---Returns a capturing rectangle in window coordinates, and also the world coordinates for the same rectangle.
|
||||
---The rectangle is sized and placed in a way that aligns as pixel perfect as possible with the world coordinates.
|
||||
---@param pos Vec2|nil -- Position of the viewport center in world coordinates. If set to nil, the viewport center will be queried automatically.
|
||||
---@param pos Vec2? -- Position of the viewport center in world coordinates. If set to nil, the viewport center will be queried automatically.
|
||||
---@return Vec2 topLeftCapture
|
||||
---@return Vec2 bottomRightCapture
|
||||
---@return Vec2 topLeftWorld
|
||||
@ -52,39 +53,50 @@ end
|
||||
---This will block until all chunks in the virtual rectangle are loaded.
|
||||
---
|
||||
---Don't set `ensureLoaded` to true when `pos` is nil!
|
||||
---@param pos Vec2|nil -- Position of the viewport center in world coordinates. If set to nil, the viewport will not be modified.
|
||||
---@param ensureLoaded boolean|nil -- If true, the function will wait until all chunks in the virtual rectangle are loaded.
|
||||
---@param dontOverwrite boolean|nil -- If true, the function will abort if there is already a file with the same coordinates.
|
||||
---@param ctx ProcessRunnerCtx|nil -- The process runner context this runs in.
|
||||
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
|
||||
local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPixelScale)
|
||||
outputPixelScale = outputPixelScale or 0
|
||||
---@param pos Vec2? -- Position of the viewport center in world coordinates. If set to nil, the viewport will not be modified.
|
||||
---@param ensureLoaded boolean? -- If true, the function will wait until all chunks in the virtual rectangle are loaded.
|
||||
---@param dontOverwrite boolean? -- If true, the function will abort if there is already a file with the same coordinates.
|
||||
---@param ctx ProcessRunnerCtx? -- The process runner context this runs in.
|
||||
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
|
||||
---@param captureDelay number? -- The number of additional frames to wait before a screen capture.
|
||||
local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPixelScale, captureDelay)
|
||||
if outputPixelScale == 0 or outputPixelScale == nil then
|
||||
outputPixelScale = Coords:PixelScale()
|
||||
end
|
||||
|
||||
local rectTopLeft, rectBottomRight = ScreenCapture.GetRect()
|
||||
if Coords.WindowResolution ~= rectBottomRight - rectTopLeft then
|
||||
error(string.format("window size seems to have changed from %s to %s", Coords.WindowResolution, rectBottomRight - rectTopLeft))
|
||||
if Coords:InternalRectSize() ~= rectBottomRight - rectTopLeft then
|
||||
error(string.format("internal rectangle size seems to have changed from %s to %s", Coords:InternalRectSize(), rectBottomRight - rectTopLeft))
|
||||
end
|
||||
|
||||
local topLeftCapture, bottomRightCapture, topLeftWorld, bottomRightWorld = calculateCaptureRectangle(pos)
|
||||
|
||||
---Top left in output coordinates.
|
||||
---@type Vec2
|
||||
local outputTopLeft
|
||||
if outputPixelScale > 0 then
|
||||
outputTopLeft = (topLeftWorld * outputPixelScale):Rounded()
|
||||
else
|
||||
outputTopLeft = topLeftWorld
|
||||
end
|
||||
local outputTopLeft = (topLeftWorld * outputPixelScale):Rounded()
|
||||
|
||||
-- Check if the file exists, and if we are allowed to overwrite it.
|
||||
if dontOverwrite and Utils.FileExists(string.format("mods/noita-mapcap/output/%d,%d.png", outputTopLeft.x, outputTopLeft.y)) then
|
||||
return
|
||||
end
|
||||
|
||||
-- Reset the count for the "Waiting for x frames." message in the UI.
|
||||
if ctx then ctx.state.WaitFrames = 0 end
|
||||
|
||||
-- Wait some additional frames.
|
||||
-- We will shake the screen a little bit so that Noita generates/populates chunks.
|
||||
if captureDelay and captureDelay > 0 then
|
||||
for _ = 1, captureDelay do
|
||||
if pos then CameraAPI.SetPos(pos + Vec2(math.random(-1, 1), math.random(-1, 1))) end
|
||||
wait(0)
|
||||
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 1 end
|
||||
end
|
||||
end
|
||||
|
||||
if pos then CameraAPI.SetPos(pos) end
|
||||
|
||||
if ensureLoaded then
|
||||
local delayFrames = 0
|
||||
if ctx then ctx.state.WaitFrames = delayFrames end
|
||||
repeat
|
||||
-- Prematurely stop capturing if that is requested by the context.
|
||||
if ctx and ctx:IsStopping() then return end
|
||||
@ -94,25 +106,43 @@ local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPi
|
||||
if pos then CameraAPI.SetPos(pos + Vec2(math.random(-10, 10), math.random(-10, 10))) end
|
||||
wait(0)
|
||||
delayFrames = delayFrames + 1
|
||||
if ctx then ctx.state.WaitFrames = delayFrames end
|
||||
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 1 end
|
||||
if pos then CameraAPI.SetPos(pos) end
|
||||
end
|
||||
|
||||
if delayFrames > 600 then
|
||||
-- Shaking wasn't enough, we will just move somewhere else an try again.
|
||||
if pos then CameraAPI.SetPos(pos + Vec2(math.random(-4000, 4000), math.random(-4000, 4000))) end
|
||||
wait(50)
|
||||
delayFrames = delayFrames + 50
|
||||
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 50 end
|
||||
if pos then CameraAPI.SetPos(pos) end
|
||||
wait(10)
|
||||
delayFrames = delayFrames + 10
|
||||
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 10 end
|
||||
end
|
||||
|
||||
wait(0)
|
||||
delayFrames = delayFrames + 1
|
||||
if ctx then ctx.state.WaitFrames = delayFrames end
|
||||
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 1 end
|
||||
|
||||
local topLeftBounds, bottomRightBounds = CameraAPI:Bounds()
|
||||
until DoesWorldExistAt(topLeftBounds.x, topLeftBounds.y, bottomRightBounds.x, bottomRightBounds.y)
|
||||
-- Chunks are loaded and will be drawn on the *next* frame.
|
||||
end
|
||||
|
||||
if ctx then ctx.state.WaitFrames = 0 end
|
||||
|
||||
-- Suspend UI drawing for 1 frame.
|
||||
UI:SuspendDrawing(1)
|
||||
|
||||
-- First we wait one frame for the current state to be drawn.
|
||||
wait(0)
|
||||
|
||||
-- Fetch coordinates again, as they may have changed.
|
||||
-- At this point the needed frame is fully drawn, but the framebuffers are swapped.
|
||||
|
||||
-- Recalculate capture position and rectangle if we are not forcing any capture position.
|
||||
-- We are in the `OnWorldPreUpdate` hook, this means that `CameraAPI.GetPos` return the position of the last frame.
|
||||
if not pos then
|
||||
topLeftCapture, bottomRightCapture, topLeftWorld, bottomRightWorld = calculateCaptureRectangle(pos)
|
||||
if outputPixelScale > 0 then
|
||||
@ -122,6 +152,10 @@ local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPi
|
||||
end
|
||||
end
|
||||
|
||||
-- Wait another frame.
|
||||
-- After this `wait` the framebuffer will be swapped again, and we can grab the correct frame.
|
||||
wait(0)
|
||||
|
||||
-- The top left world position needs to be upscaled by the pixel scale.
|
||||
-- Otherwise it's not possible to stitch the images correctly.
|
||||
if not ScreenCapture.Capture(topLeftCapture, bottomRightCapture, outputTopLeft, (bottomRightWorld - topLeftWorld) * outputPixelScale) then
|
||||
@ -132,6 +166,37 @@ local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPi
|
||||
MonitorStandby.ResetTimer()
|
||||
end
|
||||
|
||||
---Captures a screenshot of the current viewport.
|
||||
---This is used to capture animations, therefore the resulting image may not be suitable for stitching.
|
||||
---@param outputPixelScale number? The resulting image pixel to world pixel ratio.
|
||||
---@param frameNumber integer The frame number of the animation.
|
||||
local function captureScreenshotAnimation(outputPixelScale, frameNumber)
|
||||
if outputPixelScale == 0 or outputPixelScale == nil then
|
||||
outputPixelScale = Coords:PixelScale()
|
||||
end
|
||||
|
||||
local rectTopLeft, rectBottomRight = ScreenCapture.GetRect()
|
||||
if not rectTopLeft or not rectBottomRight then
|
||||
error(string.format("couldn't determine capturing rectangle"))
|
||||
end
|
||||
if Coords:InternalRectSize() ~= rectBottomRight - rectTopLeft then
|
||||
error(string.format("internal rectangle size seems to have changed from %s to %s", Coords:InternalRectSize(), rectBottomRight - rectTopLeft))
|
||||
end
|
||||
|
||||
local topLeftWorld, bottomRightWorld = Coords:ToWorld(rectTopLeft), Coords:ToWorld(rectBottomRight)
|
||||
|
||||
---We will use this to get our fame number into the filename.
|
||||
---@type Vec2
|
||||
local outputTopLeft = Vec2(frameNumber, 0)
|
||||
|
||||
if not ScreenCapture.Capture(rectTopLeft, rectBottomRight, outputTopLeft, (bottomRightWorld - topLeftWorld) * outputPixelScale) then
|
||||
error(string.format("failed to capture screenshot"))
|
||||
end
|
||||
|
||||
-- Reset monitor and PC standby every screenshot.
|
||||
MonitorStandby.ResetTimer()
|
||||
end
|
||||
|
||||
---Map capture process runner context error handler callback. Just rolls off the tongue.
|
||||
---@param err string
|
||||
---@param scope "init"|"do"|"end"
|
||||
@ -144,8 +209,9 @@ end
|
||||
---Use `Capture.MapCapturingCtx` to stop, control or view the progress.
|
||||
---@param origin Vec2 -- Center of the spiral in world pixels.
|
||||
---@param captureGridSize number -- The grid size in world pixels.
|
||||
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
|
||||
function Capture:StartCapturingSpiral(origin, captureGridSize, outputPixelScale)
|
||||
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
|
||||
---@param captureDelay number? -- The number of additional frames to wait before a screen capture.
|
||||
function Capture:StartCapturingSpiral(origin, captureGridSize, outputPixelScale, captureDelay)
|
||||
|
||||
-- Create file that signals that there are files in the output directory.
|
||||
local file = io.open("mods/noita-mapcap/output/nonempty", "a")
|
||||
@ -158,7 +224,7 @@ function Capture:StartCapturingSpiral(origin, captureGridSize, outputPixelScale)
|
||||
---The position in world coordinates.
|
||||
---Centered to the grid.
|
||||
---@type Vec2
|
||||
local pos = origin + Vec2(captureGridSize/2, captureGridSize/2)
|
||||
local pos = origin + Vec2(captureGridSize / 2, captureGridSize / 2)
|
||||
|
||||
---Process main callback.
|
||||
---@param ctx ProcessRunnerCtx
|
||||
@ -169,23 +235,23 @@ function Capture:StartCapturingSpiral(origin, captureGridSize, outputPixelScale)
|
||||
repeat
|
||||
-- +x
|
||||
for _ = 1, i, 1 do
|
||||
captureScreenshot(pos, true, true, ctx, outputPixelScale)
|
||||
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
|
||||
pos:Add(Vec2(captureGridSize, 0))
|
||||
end
|
||||
-- +y
|
||||
for _ = 1, i, 1 do
|
||||
captureScreenshot(pos, true, true, ctx, outputPixelScale)
|
||||
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
|
||||
pos:Add(Vec2(0, captureGridSize))
|
||||
end
|
||||
i = i + 1
|
||||
-- -x
|
||||
for _ = 1, i, 1 do
|
||||
captureScreenshot(pos, true, true, ctx, outputPixelScale)
|
||||
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
|
||||
pos:Add(Vec2(-captureGridSize, 0))
|
||||
end
|
||||
-- -y
|
||||
for _ = 1, i, 1 do
|
||||
captureScreenshot(pos, true, true, ctx, outputPixelScale)
|
||||
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
|
||||
pos:Add(Vec2(0, -captureGridSize))
|
||||
end
|
||||
i = i + 1
|
||||
@ -202,27 +268,32 @@ function Capture:StartCapturingSpiral(origin, captureGridSize, outputPixelScale)
|
||||
self.MapCapturingCtx:Run(nil, handleDo, handleEnd, mapCapturingCtxErrHandler)
|
||||
end
|
||||
|
||||
---Starts the capturing process of the given area.
|
||||
---Starts the capturing process of the given area using a hilbert curve.
|
||||
---Use `Capture.MapCapturingCtx` to stop, control or view the process.
|
||||
---@param topLeft Vec2 -- Top left of the to be captured rectangle.
|
||||
---@param bottomRight Vec2 -- Non included bottom left of the to be captured rectangle.
|
||||
---@param bottomRight Vec2 -- Non inclusive bottom right coordinate of the to be captured rectangle.
|
||||
---@param captureGridSize number -- The grid size in world pixels.
|
||||
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
|
||||
function Capture:StartCapturingArea(topLeft, bottomRight, captureGridSize, outputPixelScale)
|
||||
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
|
||||
---@param captureDelay number? -- The number of additional frames to wait before a screen capture.
|
||||
function Capture:StartCapturingAreaHilbert(topLeft, bottomRight, captureGridSize, outputPixelScale, captureDelay)
|
||||
|
||||
-- Create file that signals that there are files in the output directory.
|
||||
local file = io.open("mods/noita-mapcap/output/nonempty", "a")
|
||||
if file ~= nil then file:close() end
|
||||
|
||||
---The rectangle in grid coordinates.
|
||||
-- The capture offset which is needed to center the grid cells in the viewport.
|
||||
local captureOffset = Vec2(captureGridSize / 2, captureGridSize / 2)
|
||||
|
||||
-- Get the extended capture rectangle that encloses all grid cells that need to be included in the capture.
|
||||
-- In this case we only need to extend the capture area by the valid rendering rectangle.
|
||||
local validTopLeft, validBottomRight = Coords:ValidRenderingRect()
|
||||
local validTopLeftWorld, validBottomRightWorld = Coords:ToWorld(validTopLeft, topLeft + captureOffset), Coords:ToWorld(validBottomRight, bottomRight + captureOffset)
|
||||
|
||||
---The capture rectangle in grid coordinates.
|
||||
---@type Vec2, Vec2
|
||||
local gridTopLeft, gridBottomRight = (topLeft / captureGridSize):Rounded("floor"), (bottomRight / captureGridSize):Rounded("floor")
|
||||
local gridTopLeft, gridBottomRight = (validTopLeftWorld / captureGridSize):Rounded("floor"), ((validBottomRightWorld) / captureGridSize):Rounded("ceil") - Vec2(1, 1)
|
||||
|
||||
-- Handle edge cases.
|
||||
if topLeft.x == bottomRight.x then gridBottomRight.x = gridTopLeft.x end
|
||||
if topLeft.y == bottomRight.y then gridBottomRight.y = gridTopLeft.y end
|
||||
|
||||
---Size of the rectangle in grid coordinates.
|
||||
---Size of the rectangle in grid cells.
|
||||
---@type Vec2
|
||||
local gridSize = gridBottomRight - gridTopLeft
|
||||
|
||||
@ -238,7 +309,7 @@ function Capture:StartCapturingArea(topLeft, bottomRight, captureGridSize, outpu
|
||||
---@param ctx ProcessRunnerCtx
|
||||
local function handleDo(ctx)
|
||||
Modification.SetCameraFree(true)
|
||||
ctx.state = {Current = 0, Max = gridSize.x * gridSize.y}
|
||||
ctx.state = { Current = 0, Max = gridSize.x * gridSize.y }
|
||||
|
||||
while t < tLimit do
|
||||
-- Prematurely stop capturing if that is requested by the context.
|
||||
@ -251,8 +322,8 @@ function Capture:StartCapturingArea(topLeft, bottomRight, captureGridSize, outpu
|
||||
---Position in world coordinates.
|
||||
---@type Vec2
|
||||
local pos = (hilbertPos + gridTopLeft) * captureGridSize
|
||||
pos:Add(Vec2(captureGridSize/2, captureGridSize/2)) -- Move to center of grid cell.
|
||||
captureScreenshot(pos, true, true, ctx, outputPixelScale)
|
||||
pos:Add(captureOffset) -- Move to center of grid cell.
|
||||
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
|
||||
ctx.state.Current = ctx.state.Current + 1
|
||||
end
|
||||
|
||||
@ -270,9 +341,73 @@ function Capture:StartCapturingArea(topLeft, bottomRight, captureGridSize, outpu
|
||||
self.MapCapturingCtx:Run(nil, handleDo, handleEnd, mapCapturingCtxErrHandler)
|
||||
end
|
||||
|
||||
---Starts the capturing process of the given area by scanning from left to right, and top to bottom.
|
||||
---Use `Capture.MapCapturingCtx` to stop, control or view the process.
|
||||
---@param topLeft Vec2 -- Top left of the to be captured rectangle.
|
||||
---@param bottomRight Vec2 -- Non inclusive bottom right coordinate of the to be captured rectangle.
|
||||
---@param captureGridSize number -- The grid size in world pixels.
|
||||
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
|
||||
---@param captureDelay number? -- The number of additional frames to wait before a screen capture.
|
||||
function Capture:StartCapturingAreaScan(topLeft, bottomRight, captureGridSize, outputPixelScale, captureDelay)
|
||||
|
||||
-- Create file that signals that there are files in the output directory.
|
||||
local file = io.open("mods/noita-mapcap/output/nonempty", "a")
|
||||
if file ~= nil then file:close() end
|
||||
|
||||
-- The capture offset which is needed to center the grid cells in the viewport.
|
||||
local captureOffset = Vec2(captureGridSize / 2, captureGridSize / 2)
|
||||
|
||||
-- Get the extended capture rectangle that encloses all grid cells that need to be included in the capture.
|
||||
-- In this case we only need to extend the capture area by the valid rendering rectangle.
|
||||
local validTopLeft, validBottomRight = Coords:ValidRenderingRect()
|
||||
local validTopLeftWorld, validBottomRightWorld = Coords:ToWorld(validTopLeft, topLeft + captureOffset), Coords:ToWorld(validBottomRight, bottomRight + captureOffset)
|
||||
|
||||
---The capture rectangle in grid coordinates.
|
||||
---@type Vec2, Vec2
|
||||
local gridTopLeft, gridBottomRight = (validTopLeftWorld / captureGridSize):Rounded("floor"), ((validBottomRightWorld) / captureGridSize):Rounded("ceil") - Vec2(1, 1)
|
||||
|
||||
---Size of the rectangle in grid cells.
|
||||
---@type Vec2
|
||||
local gridSize = gridBottomRight - gridTopLeft
|
||||
|
||||
---Process main callback.
|
||||
---@param ctx ProcessRunnerCtx
|
||||
local function handleDo(ctx)
|
||||
Modification.SetCameraFree(true)
|
||||
ctx.state = { Current = 0, Max = gridSize.x * gridSize.y }
|
||||
|
||||
for gridY = gridTopLeft.y, gridBottomRight.y-1, 1 do
|
||||
for gridX = gridTopLeft.x, gridBottomRight.x-1, 1 do
|
||||
-- Prematurely stop capturing if that is requested by the context.
|
||||
if ctx:IsStopping() then return end
|
||||
|
||||
---Position in grid coordinates.
|
||||
---@type Vec2
|
||||
local gridPos = Vec2(gridX, gridY)
|
||||
|
||||
---Position in world coordinates.
|
||||
---@type Vec2
|
||||
local pos = gridPos * captureGridSize
|
||||
pos:Add(captureOffset) -- Move to center of grid cell.
|
||||
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
|
||||
ctx.state.Current = ctx.state.Current + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---Process end callback.
|
||||
---@param ctx ProcessRunnerCtx
|
||||
local function handleEnd(ctx)
|
||||
Modification.SetCameraFree()
|
||||
end
|
||||
|
||||
-- Run process, if there is no other running right now.
|
||||
self.MapCapturingCtx:Run(nil, handleDo, handleEnd, mapCapturingCtxErrHandler)
|
||||
end
|
||||
|
||||
---Starts the live capturing process.
|
||||
---Use `Capture.MapCapturingCtx` to stop, control or view the process.
|
||||
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
|
||||
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
|
||||
function Capture:StartCapturingLive(outputPixelScale)
|
||||
|
||||
---Queries the mod settings for the live capture parameters.
|
||||
@ -310,7 +445,7 @@ function Capture:StartCapturingLive(outputPixelScale)
|
||||
if oldPos then distanceSqr = CameraAPI.GetPos():DistanceSqr(oldPos) else distanceSqr = math.huge end
|
||||
until ctx:IsStopping() or ((delayFrames >= interval or distanceSqr >= maxDistanceSqr) and distanceSqr >= minDistanceSqr)
|
||||
|
||||
captureScreenshot(nil, false, false, ctx, outputPixelScale)
|
||||
captureScreenshot(nil, false, false, ctx, outputPixelScale, nil)
|
||||
oldPos = CameraAPI.GetPos()
|
||||
until ctx:IsStopping()
|
||||
end
|
||||
@ -325,8 +460,8 @@ function Capture:StartCapturingLive(outputPixelScale)
|
||||
self.MapCapturingCtx:Run(nil, handleDo, handleEnd, mapCapturingCtxErrHandler)
|
||||
end
|
||||
|
||||
---Gathers all entities on the screen (around x, y within radius), serializes them, appends them into entityFile and modifies those entities.
|
||||
---@param file file*|nil
|
||||
---Gathers all entities on the screen (around x, y within radius), serializes them, appends them into entityFile and/or modifies those entities.
|
||||
---@param file file*?
|
||||
---@param modify boolean
|
||||
---@param x number
|
||||
---@param y number
|
||||
@ -352,7 +487,7 @@ local function captureModifyEntities(file, modify, x, y, radius)
|
||||
file:write(",\n\t", JSON.Marshal(rootEntity), "\n", "]")
|
||||
end
|
||||
|
||||
-- Disabling this component will prevent entites from being killed/reset when they go offscreen.
|
||||
-- Disabling this component will prevent entities from being killed/reset when they go offscreen.
|
||||
-- If they are reset, all tags will be reset and we may capture these entities multiple times.
|
||||
-- This has some side effects, like longleg.xml and zombie_weak.xml will respawn every revisit, as their spawner doesn't get deleted. (Or something similar to this)
|
||||
local components = rootEntity:GetComponents("CameraBoundComponent")
|
||||
@ -434,6 +569,11 @@ local function captureModifyEntities(file, modify, x, y, radius)
|
||||
|
||||
-- Prevent it from being modified again.
|
||||
rootEntity:AddTag("MapModified")
|
||||
|
||||
-- Just a test on how to remove/kill creatures and enemies.
|
||||
--if (rootEntity:HasTag("enemy") or rootEntity:HasTag("helpless_animal")) and not rootEntity:HasTag("boss") then
|
||||
-- rootEntity:Kill()
|
||||
--end
|
||||
end
|
||||
end
|
||||
|
||||
@ -444,13 +584,13 @@ local function captureModifyEntities(file, modify, x, y, radius)
|
||||
end
|
||||
|
||||
---
|
||||
---@return file*|nil
|
||||
---@return file*?
|
||||
local function createOrOpenEntityCaptureFile()
|
||||
-- Make sure the file exists.
|
||||
local file = io.open("mods/noita-mapcap/output/entities.json", "a")
|
||||
if file ~= nil then file:close() end
|
||||
|
||||
-- Create or reopen entities CSV file.
|
||||
-- Create or reopen entities JSON file.
|
||||
file = io.open("mods/noita-mapcap/output/entities.json", "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
|
||||
|
||||
@ -503,6 +643,194 @@ function Capture:StartCapturingEntities(store, modify)
|
||||
self.EntityCapturingCtx:Run(handleInit, handleDo, handleEnd, handleErr)
|
||||
end
|
||||
|
||||
---Writes the current player position and other stats onto disk.
|
||||
---@param file file*?
|
||||
---@param pos Vec2
|
||||
---@param oldPos Vec2
|
||||
---@param hp number
|
||||
---@param maxHP number
|
||||
---@param polymorphed boolean
|
||||
local function writePlayerPathEntry(file, pos, oldPos, hp, maxHP, polymorphed)
|
||||
if not file then return end
|
||||
|
||||
local struct = {
|
||||
from = oldPos,
|
||||
to = pos,
|
||||
hp = hp,
|
||||
maxHP = maxHP,
|
||||
polymorphed = polymorphed,
|
||||
}
|
||||
|
||||
-- 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 file:seek("end") == 0 then
|
||||
-- First line.
|
||||
file:write("[\n\t", JSON.Marshal(struct), "\n", "]")
|
||||
else
|
||||
-- Following lines.
|
||||
file:seek("end", -2) -- Seek a few bytes back, so we can overwrite some stuff.
|
||||
file:write(",\n\t", JSON.Marshal(struct), "\n", "]")
|
||||
end
|
||||
|
||||
-- Ensure everything is written to disk before noita decides to crash.
|
||||
file:flush()
|
||||
end
|
||||
|
||||
---
|
||||
---@return file*?
|
||||
local function createOrOpenPlayerPathCaptureFile()
|
||||
-- Make sure the file exists.
|
||||
local file = io.open("mods/noita-mapcap/output/player-path.json", "a")
|
||||
if file ~= nil then file:close() end
|
||||
|
||||
-- Create or reopen JSON file.
|
||||
file = io.open("mods/noita-mapcap/output/player-path.json", "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
|
||||
|
||||
return file
|
||||
end
|
||||
|
||||
---Starts capturing the player path.
|
||||
---Use `Capture.PlayerPathCapturingCtx` to stop, control or view the progress.
|
||||
---@param interval integer? -- Wait time between captures in frames.
|
||||
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
|
||||
function Capture:StartCapturingPlayerPath(interval, outputPixelScale)
|
||||
interval = interval or 20
|
||||
|
||||
if outputPixelScale == 0 or outputPixelScale == nil then
|
||||
outputPixelScale = Coords:PixelScale()
|
||||
end
|
||||
|
||||
local file
|
||||
local oldPos
|
||||
|
||||
---Process initialization callback.
|
||||
---@param ctx ProcessRunnerCtx
|
||||
local function handleInit(ctx)
|
||||
-- Create output file if requested.
|
||||
file = createOrOpenPlayerPathCaptureFile()
|
||||
end
|
||||
|
||||
---Process main callback.
|
||||
---@param ctx ProcessRunnerCtx
|
||||
local function handleDo(ctx)
|
||||
repeat
|
||||
-- Get player entity, even if it is polymorphed.
|
||||
|
||||
-- For some reason Noita crashes when querying the "is_player" GameStatsComponent value on a freshly polymorphed entity found by its "player_unit" tag.
|
||||
-- It seems that the entity can still be found by the tag, but its components/values can't be accessed anymore.
|
||||
-- Solution: Don't do that.
|
||||
|
||||
---@type NoitaEntity?
|
||||
local playerEntity
|
||||
|
||||
-- Try to find the regular player entity.
|
||||
for _, entity in ipairs(EntityAPI.GetWithTag("player_unit")) do
|
||||
playerEntity = entity
|
||||
break
|
||||
end
|
||||
|
||||
-- If no player_unit entity was found, check if the player is any of the polymorphed entities.
|
||||
if not playerEntity then
|
||||
for _, entity in ipairs(EntityAPI.GetWithTag("polymorphed")) do
|
||||
local gameStatsComponent = entity:GetFirstComponent("GameStatsComponent")
|
||||
if gameStatsComponent and gameStatsComponent:GetValue("is_player") then
|
||||
playerEntity = entity
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Found some player entity.
|
||||
if playerEntity then
|
||||
-- Get position.
|
||||
local x, y, rotation, scaleX, scaleY = playerEntity:GetTransform()
|
||||
local pos = Vec2(x, y) * outputPixelScale
|
||||
|
||||
-- Get some other stats from the player.
|
||||
local damageModel = playerEntity:GetFirstComponent("DamageModelComponent")
|
||||
local hp, maxHP
|
||||
if damageModel then
|
||||
hp, maxHP = damageModel:GetValue("hp"), damageModel:GetValue("max_hp")
|
||||
end
|
||||
local polymorphed = playerEntity:HasTag("polymorphed")
|
||||
|
||||
if oldPos then writePlayerPathEntry(file, pos, oldPos, hp, maxHP, polymorphed) end
|
||||
oldPos = pos
|
||||
end
|
||||
|
||||
wait(interval)
|
||||
until ctx:IsStopping()
|
||||
end
|
||||
|
||||
---Process end callback.
|
||||
---@param ctx ProcessRunnerCtx
|
||||
local function handleEnd(ctx)
|
||||
if file then file:close() end
|
||||
end
|
||||
|
||||
---Error handler callback.
|
||||
---@param err string
|
||||
---@param scope "init"|"do"|"end"
|
||||
local function handleErr(err, scope)
|
||||
print(string.format("Failed to capture player path: %s", err))
|
||||
Message:ShowRuntimeError("PlayerPathCaptureError", "Failed to capture player path:", tostring(err))
|
||||
end
|
||||
|
||||
-- Run process, if there is no other running right now.
|
||||
self.PlayerPathCapturingCtx:Run(handleInit, handleDo, handleEnd, handleErr)
|
||||
end
|
||||
|
||||
---Starts to capture an animation.
|
||||
---This stores sequences of images that can't be stitched, but can be rendered into a video instead.
|
||||
---Use `Capture.MapCapturingCtx` to stop, control or view the process.
|
||||
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
|
||||
function Capture:StartCapturingAnimation(outputPixelScale)
|
||||
|
||||
---Queries the mod settings for the live capture parameters.
|
||||
---@return integer interval -- The interval length in frames.
|
||||
local function querySettings()
|
||||
local interval = 1--tonumber(ModSettingGet("noita-mapcap.live-interval")) or 30
|
||||
return interval
|
||||
end
|
||||
|
||||
-- Create file that signals that there are files in the output directory.
|
||||
local file = io.open("mods/noita-mapcap/output/nonempty", "a")
|
||||
if file ~= nil then file:close() end
|
||||
|
||||
---Process main callback.
|
||||
---@param ctx ProcessRunnerCtx
|
||||
local function handleDo(ctx)
|
||||
Modification.SetCameraFree(false)
|
||||
|
||||
local frame = 0
|
||||
|
||||
repeat
|
||||
local interval = querySettings()
|
||||
|
||||
-- Wait until we are allowed to take a new screenshot.
|
||||
local delayFrames = 0
|
||||
repeat
|
||||
wait(0)
|
||||
delayFrames = delayFrames + 1
|
||||
until ctx:IsStopping() or delayFrames >= interval
|
||||
|
||||
captureScreenshotAnimation(outputPixelScale, frame)
|
||||
|
||||
frame = frame + 1
|
||||
until ctx:IsStopping()
|
||||
end
|
||||
|
||||
---Process end callback.
|
||||
---@param ctx ProcessRunnerCtx
|
||||
local function handleEnd(ctx)
|
||||
Modification.SetCameraFree()
|
||||
end
|
||||
|
||||
-- Run process, if there is no other running right now.
|
||||
self.MapCapturingCtx:Run(nil, handleDo, handleEnd, mapCapturingCtxErrHandler)
|
||||
end
|
||||
|
||||
---Starts the capturing process based on user/mod settings.
|
||||
function Capture:StartCapturing()
|
||||
Message:CatchException("Capture:StartCapturing", function()
|
||||
@ -510,20 +838,26 @@ function Capture:StartCapturing()
|
||||
local mode = ModSettingGet("noita-mapcap.capture-mode")
|
||||
local outputPixelScale = ModSettingGet("noita-mapcap.pixel-scale")
|
||||
local captureGridSize = tonumber(ModSettingGet("noita-mapcap.grid-size"))
|
||||
local captureDelay = tonumber(ModSettingGet("noita-mapcap.capture-delay"))
|
||||
|
||||
if mode == "live" then
|
||||
self:StartCapturingLive(outputPixelScale)
|
||||
self:StartCapturingPlayerPath(5, outputPixelScale) -- Capture player path with an interval of 5 frames.
|
||||
elseif mode == "animation" then
|
||||
self:StartCapturingAnimation(outputPixelScale)
|
||||
elseif mode == "area" then
|
||||
local area = ModSettingGet("noita-mapcap.area")
|
||||
if area == "custom" then
|
||||
local topLeft = Vec2(ModSettingGet("noita-mapcap.area-top-left"))
|
||||
local bottomRight = Vec2(ModSettingGet("noita-mapcap.area-bottom-right"))
|
||||
|
||||
self:StartCapturingArea(topLeft, bottomRight, captureGridSize, outputPixelScale)
|
||||
self:StartCapturingAreaScan(topLeft, bottomRight, captureGridSize, outputPixelScale, captureDelay)
|
||||
else
|
||||
local predefinedArea = Config.CaptureArea[area]
|
||||
if predefinedArea then
|
||||
self:StartCapturingArea(predefinedArea.TopLeft, predefinedArea.BottomRight, captureGridSize, outputPixelScale)
|
||||
---@type fun():Vec2, Vec2
|
||||
local predefinedAreaFunction = Config.CaptureArea[area]
|
||||
if predefinedAreaFunction then
|
||||
local topLeft, bottomRight = predefinedAreaFunction()
|
||||
self:StartCapturingAreaScan(topLeft, bottomRight, captureGridSize, outputPixelScale, captureDelay)
|
||||
else
|
||||
Message:ShowRuntimeError("PredefinedArea", string.format("Unknown predefined capturing area %q", tostring(area)))
|
||||
end
|
||||
@ -532,13 +866,13 @@ function Capture:StartCapturing()
|
||||
local origin = ModSettingGet("noita-mapcap.capture-mode-spiral-origin")
|
||||
if origin == "custom" then
|
||||
local originVec = Vec2(ModSettingGet("noita-mapcap.capture-mode-spiral-origin-vector"))
|
||||
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale)
|
||||
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale, captureDelay)
|
||||
elseif origin == "0" then
|
||||
local originVec = Vec2(0, 0)
|
||||
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale)
|
||||
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale, captureDelay)
|
||||
elseif origin == "current" then
|
||||
local originVec = CameraAPI:GetPos()
|
||||
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale)
|
||||
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale, captureDelay)
|
||||
else
|
||||
Message:ShowRuntimeError("SpiralOrigin", string.format("Unknown spiral origin %q", tostring(origin)))
|
||||
end
|
||||
@ -558,4 +892,5 @@ end
|
||||
function Capture:StopCapturing()
|
||||
self.EntityCapturingCtx:Stop()
|
||||
self.MapCapturingCtx:Stop()
|
||||
self.PlayerPathCapturingCtx:Stop()
|
||||
end
|
||||
|
@ -1,4 +1,4 @@
|
||||
-- Copyright (c) 2019-2022 David Vogel
|
||||
-- Copyright (c) 2019-2024 David Vogel
|
||||
--
|
||||
-- This software is released under the MIT License.
|
||||
-- https://opensource.org/licenses/MIT
|
||||
@ -47,14 +47,14 @@ function Check:Regular(interval)
|
||||
self.Counter = interval
|
||||
|
||||
-- Remove some messages, so they will automatically disappear when the problem is solved.
|
||||
Message:CloseAutoclose()
|
||||
Message:CloseAutoClose()
|
||||
|
||||
-- Compare Noita config and actual window resolution.
|
||||
local topLeft, bottomRight = ScreenCap.GetRect() -- Actual window client area.
|
||||
if topLeft and bottomRight then
|
||||
local actual = bottomRight - topLeft
|
||||
if actual ~= Coords.WindowResolution then
|
||||
Message:ShowWrongResolution(Modification.AutoSet, string.format("Old window resolution is %s. Current resolution is %s.", Coords.WindowResolution, actual))
|
||||
if actual ~= Coords:InternalRectSize() then
|
||||
Message:ShowWrongResolution(Modification.AutoSet, string.format("Internal rectangle size is %s. Current resolution is %s.", Coords:InternalRectSize(), actual))
|
||||
end
|
||||
else
|
||||
Message:ShowRuntimeError("GetRect", "Couldn't determine window resolution.")
|
||||
@ -83,7 +83,13 @@ function Check:Regular(interval)
|
||||
if config["screenshake_intensity"] then
|
||||
local expected = config.screenshake_intensity
|
||||
if expected ~= self.StartupConfig.screenshake_intensity then
|
||||
Message:ShowSetNoitaSettings(Modification.AutoSet, string.format("Screenshake intensity is %s, expected %s.", self.StartupConfig.screenshake_intensity, expected))
|
||||
Message:ShowSetNoitaSettings(Modification.AutoSet, string.format("Screen shake intensity is %s, expected %s.", self.StartupConfig.screenshake_intensity, expected))
|
||||
end
|
||||
end
|
||||
if config["application_rendered_cursor"] then
|
||||
local expected = config.application_rendered_cursor
|
||||
if expected ~= self.StartupConfig.application_rendered_cursor then
|
||||
Message:ShowSetNoitaSettings(Modification.AutoSet, string.format("Application rendered cursor is %s, expected %s.", self.StartupConfig.application_rendered_cursor, expected))
|
||||
end
|
||||
end
|
||||
|
||||
@ -114,7 +120,7 @@ function Check:Regular(interval)
|
||||
-- This is not perfect, as it doesn't take rounding and cropping into account, so the actual captured area may be a few pixels smaller.
|
||||
local mode = ModSettingGet("noita-mapcap.capture-mode")
|
||||
local captureGridSize = tonumber(ModSettingGet("noita-mapcap.grid-size"))
|
||||
if mode ~= "live" and (Coords.VirtualResolution.x < captureGridSize or Coords.VirtualResolution.y < captureGridSize) then
|
||||
if (mode ~= "live" and mode ~= "animation") and (Coords.VirtualResolution.x < captureGridSize or Coords.VirtualResolution.y < captureGridSize) then
|
||||
Message:ShowGeneralSettingsProblem(
|
||||
"The virtual resolution is smaller than the capture grid size.",
|
||||
"This means that you will get black areas in your final stitched image.",
|
||||
|
@ -1,8 +1,9 @@
|
||||
-- Copyright (c) 2022 David Vogel
|
||||
-- Copyright (c) 2022-2024 David Vogel
|
||||
--
|
||||
-- This software is released under the MIT License.
|
||||
-- https://opensource.org/licenses/MIT
|
||||
|
||||
local NXML = require("luanxml.nxml")
|
||||
local Vec2 = require("noita-api.vec2")
|
||||
|
||||
-- List of components that will be disabled on every encountered entity.
|
||||
@ -27,22 +28,65 @@ Config.ComponentsToDisable = {
|
||||
--"AudioComponent",
|
||||
}
|
||||
|
||||
local CHUNK_SIZE = 512
|
||||
|
||||
---Returns the rectangle of the base area as two vectors.
|
||||
---@return Vec2 TopLeft Top left corner in world coordinates.
|
||||
---@return Vec2 BottomRight Bottom right corner in world coordinates. This pixel is not included in the final rectangle.
|
||||
local function getBaseArea()
|
||||
local xml = NXML.parse(ModTextFileGetContent("data/biome/_biomes_all.xml"))
|
||||
local width, height = BiomeMapGetSize()
|
||||
local offsetX, offsetY = math.floor(width/2), xml.attr.biome_offset_y -- TODO: This may not be right. Check what Noita is really doing when we have a biome map with an odd width.
|
||||
return Vec2(-offsetX, -offsetY)*CHUNK_SIZE, Vec2(-offsetX+width, -offsetY+height)*CHUNK_SIZE
|
||||
--return Vec2(-17920, -7168), Vec2(17920, 17408) -- Coordinates for a "New Game" without mods or anything.
|
||||
end
|
||||
|
||||
---A list of capture areas.
|
||||
---This contains functions that determine the capture area based on the biome size and other parameters.
|
||||
---The returned vectors are the top left corner, and the bottom right corner of the capture area in world coordinates.
|
||||
---The bottom right corner pixel is not included in the rectangle.
|
||||
---@type table<string, fun():Vec2, Vec2>
|
||||
Config.CaptureArea = {
|
||||
-- Base layout: Every part outside this is based on a similar layout, but uses different materials/seeds.
|
||||
["1x1"] = {
|
||||
TopLeft = Vec2(-17920, -7168), -- in world coordinates.
|
||||
BottomRight = Vec2(17920, 17408), -- in world coordinates. This pixel is not included in the rectangle.
|
||||
},
|
||||
["1x1"] = getBaseArea,
|
||||
|
||||
-- Main world: The main world with 3 parts: sky, normal and hell.
|
||||
["1x3"] = {
|
||||
TopLeft = Vec2(-17920, -31744), -- in world coordinates.
|
||||
BottomRight = Vec2(17920, 41984), -- in world coordinates. This pixel is not included in the rectangle.
|
||||
},
|
||||
["1x3"] = function()
|
||||
local width, height = BiomeMapGetSize()
|
||||
local topLeft, bottomRight = getBaseArea()
|
||||
return topLeft + Vec2(0, -height)*CHUNK_SIZE, bottomRight + Vec2(0, height)*CHUNK_SIZE
|
||||
--return Vec2(-17920, -31744), Vec2(17920, 41984) -- Coordinates for a "New Game" without mods or anything.
|
||||
end,
|
||||
|
||||
-- -1 parallel world: The parallel world with 3 parts: sky, normal and hell.
|
||||
["1x3 -1"] = function()
|
||||
local width, height = BiomeMapGetSize()
|
||||
local topLeft, bottomRight = getBaseArea()
|
||||
return topLeft + Vec2(-width, -height)*CHUNK_SIZE, bottomRight + Vec2(-width, height)*CHUNK_SIZE
|
||||
--return Vec2(-17920, -31744) + Vec2(-35840, 0), Vec2(17920, 41984) + Vec2(-35840, 0) -- Coordinates for a "New Game" without mods or anything.
|
||||
end,
|
||||
|
||||
-- +1 parallel world: The parallel world with 3 parts: sky, normal and hell.
|
||||
["1x3 +1"] = function()
|
||||
local width, height = BiomeMapGetSize()
|
||||
local topLeft, bottomRight = getBaseArea()
|
||||
return topLeft + Vec2(width, -height)*CHUNK_SIZE, bottomRight + Vec2(width, height)*CHUNK_SIZE
|
||||
--return Vec2(-17920, -31744) + Vec2(35840, 0), Vec2(17920, 41984) + Vec2(35840, 0) -- Coordinates for a "New Game" without mods or anything.
|
||||
end,
|
||||
|
||||
-- Extended: Main world + a fraction of the parallel worlds to the left and right.
|
||||
["1.5x3"] = {
|
||||
TopLeft = Vec2(-25600, -31744), -- in world coordinates.
|
||||
BottomRight = Vec2(25600, 41984), -- in world coordinates. This pixel is not included in the rectangle.
|
||||
},
|
||||
["1.5x3"] = function()
|
||||
local width, height = BiomeMapGetSize()
|
||||
local topLeft, bottomRight = getBaseArea()
|
||||
return topLeft + Vec2(-math.floor(0.25*width), -height)*CHUNK_SIZE, bottomRight + Vec2(math.floor(0.25*width), height)*CHUNK_SIZE
|
||||
--return Vec2(-25600, -31744), Vec2(25600, 41984) -- Coordinates for a "New Game" without mods or anything. These coordinates may not exactly be 1.5 of the base width for historic reasons.
|
||||
end,
|
||||
|
||||
-- Extended: Main world + each parallel world to the left and right.
|
||||
["3x3"] = function()
|
||||
local width, height = BiomeMapGetSize()
|
||||
local topLeft, bottomRight = getBaseArea()
|
||||
return topLeft + Vec2(-width, -height)*CHUNK_SIZE, bottomRight + Vec2(width, height)*CHUNK_SIZE
|
||||
--return Vec2(-53760, -31744), Vec2(53760, 41984) -- Coordinates for a "New Game" without mods or anything.
|
||||
end,
|
||||
}
|
||||
|
@ -126,7 +126,7 @@ function Coords:PixelScale()
|
||||
end
|
||||
|
||||
---Converts the given virtual/world coordinates into window/screen coordinates.
|
||||
---@param world Vec2 -- World coordiante, origin is near the cave entrance.
|
||||
---@param world Vec2 -- World coordinate, origin is near the cave entrance.
|
||||
---@param viewportCenter Vec2|nil -- Result of `GameGetCameraPos()`. Will be queried automatically if set to nil.
|
||||
---@return Vec2 window
|
||||
function Coords:ToWindow(world, viewportCenter)
|
||||
|
58
files/libraries/memory.lua
Normal file
58
files/libraries/memory.lua
Normal file
@ -0,0 +1,58 @@
|
||||
-- Copyright (c) 2022 David Vogel
|
||||
--
|
||||
-- This software is released under the MIT License.
|
||||
-- https://opensource.org/licenses/MIT
|
||||
|
||||
local ffi = require("ffi")
|
||||
|
||||
local Memory = {}
|
||||
|
||||
if ffi.abi'64bit' then
|
||||
ffi.cdef([[
|
||||
typedef uint64_t __uint3264;
|
||||
]])
|
||||
else
|
||||
ffi.cdef([[
|
||||
typedef uint32_t __uint3264;
|
||||
]])
|
||||
end
|
||||
|
||||
ffi.cdef([[
|
||||
typedef void VOID;
|
||||
typedef VOID *LPVOID;
|
||||
typedef int BOOL;
|
||||
typedef __uint3264 ULONG_PTR, *PULONG_PTR;
|
||||
typedef ULONG_PTR SIZE_T, *PSIZE_T;
|
||||
typedef unsigned long DWORD;
|
||||
typedef DWORD *PDWORD;
|
||||
|
||||
BOOL VirtualProtect(LPVOID lpAddress, SIZE_T dwSize, DWORD flNewProtect, PDWORD lpflOldProtect);
|
||||
]])
|
||||
|
||||
Memory.PAGE_NOACCESS = 0x01
|
||||
Memory.PAGE_READONLY = 0x02
|
||||
Memory.PAGE_READWRITE = 0x04
|
||||
Memory.PAGE_WRITECOPY = 0x08
|
||||
Memory.PAGE_EXECUTE = 0x10
|
||||
Memory.PAGE_EXECUTE_READ = 0x20
|
||||
Memory.PAGE_EXECUTE_READWRITE = 0x40
|
||||
Memory.PAGE_EXECUTE_WRITECOPY = 0x80
|
||||
Memory.PAGE_GUARD = 0x100
|
||||
Memory.PAGE_NOCACHE = 0x200
|
||||
Memory.PAGE_WRITECOMBINE = 0x400
|
||||
|
||||
---Changes the protection on a region of committed pages in the virtual address space of the calling process.
|
||||
---@param addr ffi.cdata*
|
||||
---@param size integer
|
||||
---@param newProtect integer
|
||||
---@return ffi.cdata* oldProtect
|
||||
function Memory.VirtualProtect(addr, size, newProtect)
|
||||
local oldProtect = ffi.new('DWORD[1]')
|
||||
if not ffi.C.VirtualProtect(addr, size, newProtect, oldProtect) then
|
||||
error(string.format("failed to call VirtualProtect(%s, %s, %s)", addr, size, newProtect))
|
||||
end
|
||||
|
||||
return oldProtect
|
||||
end
|
||||
|
||||
return Memory
|
@ -18,7 +18,7 @@ local CameraAPI = {}
|
||||
---
|
||||
---@param strength number
|
||||
---@param position Vec2|nil -- Defaults to camera position if not set.
|
||||
function CameraAPI.Screenshake(strength, position)
|
||||
function CameraAPI.ScreenShake(strength, position)
|
||||
if position == nil then
|
||||
return GameScreenshake(strength)
|
||||
end
|
||||
|
@ -28,7 +28,7 @@ end
|
||||
-- Package doesn't exist when the Lua API is restricted.
|
||||
-- Therefore we create it here and apply some default values.
|
||||
package = package or {}
|
||||
package.path = package.path or "./?.lua;" -- Allow paths relative to the working directory.
|
||||
package.path = package.path or "./?.lua" -- Allow paths relative to the working directory.
|
||||
package.preload = package.preload or {}
|
||||
package.loaded = package.loaded or {
|
||||
_G = _G,
|
||||
@ -64,7 +64,7 @@ local oldRequire = require
|
||||
local recursionSet = {}
|
||||
|
||||
---Emulated require function in case the Lua API is restricted.
|
||||
---It's probably good enough for most usecases.
|
||||
---It's probably good enough for most use cases.
|
||||
---
|
||||
---We need to override the default require in any case, as only dofile and loadfile can access stuff in the virtual filesystem.
|
||||
---@param modName string
|
||||
@ -141,12 +141,13 @@ end
|
||||
---Set up some stuff so `require` works as expected.
|
||||
---@param libPath any -- Path to the libraries directory of this mod.
|
||||
local function setup(libPath)
|
||||
-- Add the files folder of the given mod as base for any `require` lookups.
|
||||
package.path = package.path .. "./" .. libPath .. "?.lua;"
|
||||
package.path = package.path .. "./" .. libPath .. "?/init.lua;"
|
||||
-- Add the library directory of the mod as base for any `require` lookups.
|
||||
package.path = package.path:gsub(";$", "") -- Get rid of any trailing semicolon.
|
||||
package.path = package.path .. ";./" .. libPath .. "?.lua"
|
||||
package.path = package.path .. ";./" .. libPath .. "?/init.lua"
|
||||
|
||||
-- Add the library directory of Noita itself.
|
||||
package.path = package.path .. "./data/scripts/lib/?.lua;" -- TODO: Get rid of Noita's lib path, create replacement libs for stuff in there
|
||||
package.path = package.path .. ";./data/scripts/lib/?.lua" -- TODO: Get rid of Noita's lib path, create replacement libs for stuff in there
|
||||
end
|
||||
|
||||
return setup
|
||||
|
@ -64,7 +64,7 @@ function NoitaComponent:SetValue(fieldName, ...)
|
||||
return ComponentSetValue2(self.ID, fieldName, ...) -- TODO: Rework Noita API to handle vectors, and use a vector instead of shitty multi value arguments
|
||||
end
|
||||
|
||||
---Returns one or many values matching the type or subtypes of the requested field in a component subobject.
|
||||
---Returns one or many values matching the type or subtypes of the requested field in a component sub-object.
|
||||
---Reports error and returns nil if the field type is not supported or 'object_name' is not a metaobject.
|
||||
---
|
||||
---Reporting errors means that it spams the stdout with messages, instead of using the lua error handling. Thanks Nolla.
|
||||
@ -75,7 +75,7 @@ function NoitaComponent:ObjectGetValue(objectName, fieldName)
|
||||
return ComponentObjectGetValue2(self.ID, objectName, fieldName) -- TODO: Rework Noita API to handle vectors, and return a vector instead of some shitty multi value result
|
||||
end
|
||||
|
||||
---Sets the value of a field in a component subobject. Value(s) should have a type matching the field type.
|
||||
---Sets the value of a field in a component sub-object. Value(s) should have a type matching the field type.
|
||||
---Reports error if the values weren't given in correct type, the field type is not supported or 'object_name' is not a metaobject.
|
||||
---@param objectName string
|
||||
---@param fieldName string
|
||||
@ -134,6 +134,16 @@ function NoitaComponent:GetTypeName()
|
||||
return ComponentGetTypeName(self.ID)
|
||||
end
|
||||
|
||||
-- TODO: Add missing Noita API methods and functions.
|
||||
|
||||
---
|
||||
---@return NoitaComponent|nil
|
||||
function ComponentAPI.GetUpdatedComponent()
|
||||
return ComponentAPI.Wrap(GetUpdatedComponentID())
|
||||
end
|
||||
|
||||
-- TODO: Add missing Noita API methods and functions.
|
||||
|
||||
-------------------------
|
||||
-- JSON Implementation --
|
||||
-------------------------
|
||||
|
@ -48,7 +48,7 @@ end
|
||||
|
||||
---Enables the trailer mode and some other things:
|
||||
---
|
||||
--- - Disables ingame GUI.
|
||||
--- - Disables in-game GUI.
|
||||
--- - Opens fog of war everywhere (Not the same as disabling it completely).
|
||||
--- - Enables `mTrailerMode`, whatever that does.
|
||||
---
|
||||
|
@ -35,7 +35,7 @@ end
|
||||
---@param posX number -- X coordinate in world (virtual) pixels.
|
||||
---@param posY number -- Y coordinate in world (virtual) pixels.
|
||||
---@return NoitaEntity|nil
|
||||
function EntityAPI.Load(filename, posX, posY)
|
||||
function EntityAPI.Load(filename, posX, posY) -- TODO: Change to use Vec2 object
|
||||
return EntityAPI.Wrap(EntityLoad(filename, posX, posY))
|
||||
end
|
||||
|
||||
@ -44,7 +44,7 @@ end
|
||||
---@param posX number -- X coordinate in world (virtual) pixels.
|
||||
---@param posY number -- Y coordinate in world (virtual) pixels.
|
||||
---@return NoitaEntity|nil
|
||||
function EntityAPI.LoadEndGameItem(filename, posX, posY)
|
||||
function EntityAPI.LoadEndGameItem(filename, posX, posY) -- TODO: Change to use Vec2 object
|
||||
return EntityAPI.Wrap(EntityLoadEndGameItem(filename, posX, posY))
|
||||
end
|
||||
|
||||
@ -52,7 +52,7 @@ end
|
||||
---@param filename string
|
||||
---@param posX number -- X coordinate in world (virtual) pixels.
|
||||
---@param posY number -- Y coordinate in world (virtual) pixels.
|
||||
function EntityAPI.LoadCameraBound(filename, posX, posY)
|
||||
function EntityAPI.LoadCameraBound(filename, posX, posY) -- TODO: Change to use Vec2 object
|
||||
return EntityLoadCameraBound(filename, posX, posY)
|
||||
end
|
||||
|
||||
@ -152,7 +152,7 @@ end
|
||||
---@param rotation number
|
||||
---@param scaleX number
|
||||
---@param scaleY number
|
||||
function NoitaEntity:SetTransform(x, y, rotation, scaleX, scaleY)
|
||||
function NoitaEntity:SetTransform(x, y, rotation, scaleX, scaleY) -- TODO: Change to use Vec2 object
|
||||
return EntitySetTransform(self.ID, x, y, rotation, scaleX, scaleY)
|
||||
end
|
||||
|
||||
@ -162,13 +162,13 @@ end
|
||||
---@param rotation number
|
||||
---@param scaleX number
|
||||
---@param scaleY number
|
||||
function NoitaEntity:SetAndApplyTransform(x, y, rotation, scaleX, scaleY)
|
||||
function NoitaEntity:SetAndApplyTransform(x, y, rotation, scaleX, scaleY) -- TODO: Change to use Vec2 object
|
||||
return EntityApplyTransform(self.ID, x, y, rotation, scaleX, scaleY)
|
||||
end
|
||||
|
||||
---Returns the transformation of the entity.
|
||||
---@return number x, number y, number rotation, number scaleX, number scaleY
|
||||
function NoitaEntity:GetTransform()
|
||||
function NoitaEntity:GetTransform() -- TODO: Change to use Vec2 object
|
||||
return EntityGetTransform(self.ID)
|
||||
end
|
||||
|
||||
@ -261,7 +261,7 @@ end
|
||||
---@param posY number -- X coordinate in world (virtual) pixels.
|
||||
---@param radius number -- Radius in world (virtual) pixels.
|
||||
---@return NoitaEntity[]
|
||||
function EntityAPI.GetInRadius(posX, posY, radius)
|
||||
function EntityAPI.GetInRadius(posX, posY, radius) -- TODO: Change to use Vec2 object
|
||||
local entityIDs = EntityGetInRadius(posX, posY, radius) or {}
|
||||
local result = {}
|
||||
for _, entityID in ipairs(entityIDs) do
|
||||
@ -276,7 +276,7 @@ end
|
||||
---@param radius number -- Radius in world (virtual) pixels.
|
||||
---@param tag string
|
||||
---@return NoitaEntity[]
|
||||
function EntityAPI.GetInRadiusWithTag(posX, posY, radius, tag)
|
||||
function EntityAPI.GetInRadiusWithTag(posX, posY, radius, tag) -- TODO: Change to use Vec2 object
|
||||
local entityIDs = EntityGetInRadiusWithTag(posX, posY, radius, tag) or {}
|
||||
local result = {}
|
||||
for _, entityID in ipairs(entityIDs) do
|
||||
@ -289,7 +289,7 @@ end
|
||||
---@param posX number -- X coordinate in world (virtual) pixels.
|
||||
---@param posY number -- X coordinate in world (virtual) pixels.
|
||||
---@return NoitaEntity|nil
|
||||
function EntityAPI.GetClosest(posX, posY)
|
||||
function EntityAPI.GetClosest(posX, posY) -- TODO: Change to use Vec2 object
|
||||
return EntityAPI.Wrap(EntityGetClosest(posX, posY))
|
||||
end
|
||||
|
||||
@ -327,7 +327,7 @@ end
|
||||
|
||||
---Creates a component of type 'component_type_name' and adds it to 'entity_id'.
|
||||
---'table_of_component_values' should be a string-indexed table, where keys are field names and values are field values of correct type.
|
||||
---The value setting works like ComponentObjectSetValue2(), with the exception that multivalue types are not supported.
|
||||
---The value setting works like ComponentObjectSetValue2(), with the exception that multi value types are not supported.
|
||||
---Additional supported values are _tags:comma_separated_string and _enabled:bool, which basically work like the those fields work in entity XML files.
|
||||
---Returns the created component, if creation succeeded, or nil.
|
||||
---@param componentTypeName string
|
||||
@ -340,6 +340,72 @@ end
|
||||
|
||||
-- TODO: Add missing Noita API methods and functions.
|
||||
|
||||
---
|
||||
---@return NoitaEntity|nil
|
||||
function EntityAPI.GetUpdatedEntity()
|
||||
return EntityAPI.Wrap(GetUpdatedEntityID())
|
||||
end
|
||||
|
||||
---
|
||||
---@return NoitaEntity|nil
|
||||
function EntityAPI.GetWorldStateEntity()
|
||||
return EntityAPI.Wrap(GameGetWorldStateEntity())
|
||||
end
|
||||
|
||||
---
|
||||
---@return NoitaEntity|nil
|
||||
function EntityAPI.GetPlayerStatsEntity()
|
||||
return EntityAPI.Wrap(GameGetPlayerStatsEntity())
|
||||
end
|
||||
|
||||
-- TODO: Add missing Noita API methods and functions.
|
||||
|
||||
---
|
||||
function NoitaEntity:RegenItemAction()
|
||||
return GameRegenItemAction(self.ID)
|
||||
end
|
||||
|
||||
---
|
||||
function NoitaEntity:RegenItemActionsInContainer()
|
||||
return GameRegenItemActionsInContainer(self.ID)
|
||||
end
|
||||
|
||||
---
|
||||
function NoitaEntity:RegenItemActionsInPlayer()
|
||||
return GameRegenItemActionsInPlayer(self.ID)
|
||||
end
|
||||
|
||||
---
|
||||
---@param itemEntity NoitaEntity
|
||||
function NoitaEntity:KillInventoryItem(itemEntity)
|
||||
return GameKillInventoryItem(self.ID, itemEntity.ID)
|
||||
end
|
||||
|
||||
---
|
||||
---@param itemEntity NoitaEntity
|
||||
---@param doPickUpEffects boolean
|
||||
function NoitaEntity:PickUpInventoryItem(itemEntity, doPickUpEffects)
|
||||
if doPickUpEffects == nil then doPickUpEffects = true end
|
||||
return GamePickUpInventoryItem(self.ID, itemEntity.ID, doPickUpEffects)
|
||||
end
|
||||
|
||||
---
|
||||
function NoitaEntity:DropAllItems()
|
||||
return GameDropAllItems(self.ID)
|
||||
end
|
||||
|
||||
---
|
||||
function NoitaEntity:DropPlayerInventoryItems()
|
||||
return GameDropPlayerInventoryItems(self.ID)
|
||||
end
|
||||
|
||||
---
|
||||
function NoitaEntity:DestroyInventoryItems()
|
||||
return GameDestroyInventoryItems(self.ID)
|
||||
end
|
||||
|
||||
-- TODO: Add missing Noita API methods and functions.
|
||||
|
||||
---
|
||||
---@return boolean
|
||||
function NoitaEntity:IsPlayer()
|
||||
|
@ -53,7 +53,15 @@ end
|
||||
---@param val number
|
||||
---@return string
|
||||
function lib.MarshalNumber(val)
|
||||
-- TODO: Marshal NaN, +Inf, -Inf, ... correctly
|
||||
-- Edge cases, as there is no real solution to this.
|
||||
-- JSON can't store special IEEE754 values, this is dumb as hell.
|
||||
if val ~= val then
|
||||
return "null" -- Alternatively we could output the string "NaN" (With quotes).
|
||||
elseif val <= -math.huge then
|
||||
return "-1E+600" -- Just output a stupidly large number.
|
||||
elseif val >= math.huge then
|
||||
return "1E+600" -- Just output a stupidly large number.
|
||||
end
|
||||
|
||||
return tostring(val)
|
||||
end
|
||||
|
@ -3,7 +3,7 @@
|
||||
-- This software is released under the MIT License.
|
||||
-- https://opensource.org/licenses/MIT
|
||||
|
||||
-- This contains just some utilitites that may be useful to have.
|
||||
-- This contains just some utilities that may be useful to have.
|
||||
|
||||
local DebugAPI = require("noita-api.debug")
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
-- Copyright (c) 2019-2022 David Vogel
|
||||
-- Copyright (c) 2019-2023 David Vogel
|
||||
--
|
||||
-- This software is released under the MIT License.
|
||||
-- https://opensource.org/licenses/MIT
|
||||
@ -24,6 +24,13 @@ ffi.cdef([[
|
||||
LONG bottom;
|
||||
} RECT;
|
||||
|
||||
typedef struct {
|
||||
LONG x;
|
||||
LONG y;
|
||||
LONG width;
|
||||
LONG height;
|
||||
} GLViewportDims;
|
||||
|
||||
bool GetRect(RECT* rect);
|
||||
bool Capture(RECT* rect, int x, int y, int sx, int sy);
|
||||
]])
|
||||
|
@ -1,3 +1,3 @@
|
||||
<MagicNumbers
|
||||
DEBUG_FREE_CAMERA_SPEED="1"
|
||||
DEBUG_FREE_CAMERA_SPEED="10"
|
||||
></MagicNumbers>
|
||||
|
@ -1,4 +1,4 @@
|
||||
-- Copyright (c) 2019-2022 David Vogel
|
||||
-- Copyright (c) 2019-2024 David Vogel
|
||||
--
|
||||
-- This software is released under the MIT License.
|
||||
-- https://opensource.org/licenses/MIT
|
||||
@ -12,18 +12,19 @@
|
||||
--------------------------
|
||||
|
||||
local Coords = require("coordinates")
|
||||
local DebugAPI = require("noita-api.debug")
|
||||
|
||||
----------
|
||||
-- Code --
|
||||
----------
|
||||
|
||||
---Removes all messages with the autoclose flag.
|
||||
---Removes all messages with the AutoClose flag.
|
||||
---Use this before you recreate all auto closing messages.
|
||||
function Message:CloseAutoclose()
|
||||
function Message:CloseAutoClose()
|
||||
self.List = self.List or {}
|
||||
|
||||
for k, message in pairs(self.List) do
|
||||
if message.Autoclose then
|
||||
if message.AutoClose then
|
||||
self.List[k] = nil
|
||||
end
|
||||
end
|
||||
@ -64,7 +65,7 @@ function Message:ShowResetNoitaSettings()
|
||||
Lines = {
|
||||
"You requested to reset some game settings like:",
|
||||
"- Custom resolutions",
|
||||
"- Screenshake intensity",
|
||||
"- Screen-shake intensity",
|
||||
" ",
|
||||
"Press the following button to reset the settings and close Noita automatically:",
|
||||
},
|
||||
@ -94,7 +95,7 @@ function Message:ShowSetNoitaSettings(callback, desc)
|
||||
Actions = {
|
||||
{ Name = "Setup and close (May corrupt current save!)", Hint = nil, HintDesc = nil, Callback = callback },
|
||||
},
|
||||
Autoclose = true, -- This message will automatically close.
|
||||
AutoClose = true, -- This message will automatically close.
|
||||
}
|
||||
end
|
||||
|
||||
@ -111,7 +112,7 @@ function Message:ShowRequestRestart(desc)
|
||||
" ",
|
||||
"To resolve this issue, restart the game.",
|
||||
},
|
||||
Autoclose = true, -- This message will automatically close.
|
||||
AutoClose = true, -- This message will automatically close.
|
||||
}
|
||||
end
|
||||
|
||||
@ -127,12 +128,15 @@ function Message:ShowWrongResolution(callback, desc)
|
||||
"The resolution changed:",
|
||||
desc or "",
|
||||
" ",
|
||||
"To fix: Restart Noita or revert the change."
|
||||
"Press the button at the bottom to set up and close Noita automatically.",
|
||||
" ",
|
||||
"You can always reset any custom settings by right clicking the `start capture`",
|
||||
"button at the top left.",
|
||||
},
|
||||
Actions = {
|
||||
{ Name = "Query settings again", Hint = nil, HintDesc = nil, Callback = function() Coords:ReadResolutions() end },
|
||||
{ Name = "Setup and close (May corrupt current save!)", Hint = nil, HintDesc = nil, Callback = callback },
|
||||
},
|
||||
Autoclose = true, -- This message will automatically close.
|
||||
AutoClose = true, -- This message will automatically close.
|
||||
}
|
||||
end
|
||||
|
||||
@ -162,7 +166,7 @@ function Message:ShowGeneralSettingsProblem(...)
|
||||
self.List["GeneralSettingsProblem"] = {
|
||||
Type = "hint",
|
||||
Lines = { ... },
|
||||
Autoclose = true, -- This message will automatically close.
|
||||
AutoClose = true, -- This message will automatically close.
|
||||
}
|
||||
end
|
||||
|
||||
@ -176,3 +180,61 @@ function Message:ShowGeneralInstallationProblem(...)
|
||||
Lines = { ... },
|
||||
}
|
||||
end
|
||||
|
||||
---Tell the user that some modification couldn't be applied because it is unsupported.
|
||||
---@param realm "config"|"magicNumbers"|"processMemory"|"filePatches"
|
||||
---@param name string
|
||||
---@param value any
|
||||
function Message:ShowModificationUnsupported(realm, name, value)
|
||||
self.List = self.List or {}
|
||||
|
||||
self.List["ModificationFailed"] = self.List["ModificationFailed"] or {
|
||||
Type = "warning",
|
||||
}
|
||||
|
||||
-- Create or append to list of modifications.
|
||||
-- We have to prevent duplicate entries.
|
||||
self.List["ModificationFailed"].ModificationEntries = self.List["ModificationFailed"].ModificationEntries or {}
|
||||
local found
|
||||
for _, modEntry in ipairs(self.List["ModificationFailed"].ModificationEntries) do
|
||||
if modEntry.realm == realm and modEntry.name == name then
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not found then
|
||||
table.insert(self.List["ModificationFailed"].ModificationEntries, {realm = realm, name = name, value = value})
|
||||
end
|
||||
|
||||
-- Build message lines.
|
||||
self.List["ModificationFailed"].Lines = {"The mod couldn't apply the following changes:"}
|
||||
table.insert(self.List["ModificationFailed"].Lines, " ")
|
||||
for _, modEntry in ipairs(self.List["ModificationFailed"].ModificationEntries) do
|
||||
table.insert(self.List["ModificationFailed"].Lines, string.format("- %q in %q realm", modEntry.name, modEntry.realm))
|
||||
end
|
||||
|
||||
table.insert(self.List["ModificationFailed"].Lines, " ")
|
||||
table.insert(self.List["ModificationFailed"].Lines, "This simply means that the mod can't automatically apply this change in the Noita version you are using.")
|
||||
table.insert(self.List["ModificationFailed"].Lines, "If you are running a non-beta version of Noita, feel free to open an issue at https://github.com/Dadido3/noita-mapcap.")
|
||||
|
||||
-- Tell the user to change some settings manually, if possible.
|
||||
local manuallyWithF7 = {}
|
||||
local possibleManualWithF7 = {mPostFxDisabled = true, mGuiDisabled = true, mGuiHalfSize = true, mFogOfWarOpenEverywhere = true, mTrailerMode = true, mDayTimeRotationPause = true, mPlayerNeverDies = true, mFreezeAI = true}
|
||||
for _, modEntry in ipairs(self.List["ModificationFailed"].ModificationEntries) do
|
||||
if modEntry.realm == "processMemory" and possibleManualWithF7[modEntry.name] then
|
||||
table.insert(manuallyWithF7, modEntry)
|
||||
end
|
||||
end
|
||||
|
||||
if #manuallyWithF7 > 0 then
|
||||
table.insert(self.List["ModificationFailed"].Lines, " ")
|
||||
table.insert(self.List["ModificationFailed"].Lines, "You can apply the setting manually:")
|
||||
table.insert(self.List["ModificationFailed"].Lines, " ")
|
||||
table.insert(self.List["ModificationFailed"].Lines, "- Press F7 to open the debug menu.")
|
||||
for _, modEntry in ipairs(manuallyWithF7) do
|
||||
table.insert(self.List["ModificationFailed"].Lines, string.format("- Change %q to %q.", modEntry.name, modEntry.value))
|
||||
end
|
||||
table.insert(self.List["ModificationFailed"].Lines, "- Press F7 again to close the menu.")
|
||||
table.insert(self.List["ModificationFailed"].Lines, "- Close this warning when you are done.")
|
||||
end
|
||||
end
|
||||
|
@ -1,4 +1,4 @@
|
||||
-- Copyright (c) 2022 David Vogel
|
||||
-- Copyright (c) 2022-2024 David Vogel
|
||||
--
|
||||
-- This software is released under the MIT License.
|
||||
-- https://opensource.org/licenses/MIT
|
||||
@ -19,9 +19,11 @@
|
||||
local CameraAPI = require("noita-api.camera")
|
||||
local Coords = require("coordinates")
|
||||
local ffi = require("ffi")
|
||||
local Memory = require("memory")
|
||||
local NXML = require("luanxml.nxml")
|
||||
local Utils = require("noita-api.utils")
|
||||
local Vec2 = require("noita-api.vec2")
|
||||
local DebugAPI = require("noita-api.debug")
|
||||
|
||||
----------
|
||||
-- Code --
|
||||
@ -92,29 +94,316 @@ end
|
||||
---@param memory table
|
||||
function Modification.SetMemoryOptions(memory)
|
||||
-- Lookup table with the following hierarchy:
|
||||
-- DevBuild -> OS -> BuildDate -> Option -> Address.
|
||||
-- DevBuild -> OS -> BuildDate -> Option -> ModFunc.
|
||||
local lookup = {
|
||||
[true] = {
|
||||
Windows = {
|
||||
[0x00F77B0C] = { _BuildString = "Build Apr 23 2021 18:36:55", -- GOG build.
|
||||
mPostFxDisabled = 0x010E3B6C,
|
||||
mGuiDisabled = 0x010E3B6D,
|
||||
mGuiHalfSize = 0x010E3B6E,
|
||||
mFogOfWarOpenEverywhere = 0x010E3B6F,
|
||||
mTrailerMode = 0x010E3B70,
|
||||
mDayTimeRotationPause = 0x010E3B71,
|
||||
mPlayerNeverDies = 0x010E3B72,
|
||||
mFreezeAI = 0x010E3B73,
|
||||
{_Offset = 0x00F77B0C, _BuildString = "Build Apr 23 2021 18:36:55", -- GOG dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x010E3B6C)[0] = value end, -- Can be found by using Cheat Engine while toggling options in the F7 menu.
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x010E3B6D)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x010E3B6E)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010E3B6F)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x010E3B70)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010E3B71)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010E3B72)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x010E3B73)[0] = value end,
|
||||
},
|
||||
[0x00F80384] = { _BuildString = "Build Apr 23 2021 18:40:40", -- Steam build.
|
||||
mPostFxDisabled = 0x010EDEBC,
|
||||
mGuiDisabled = 0x010EDEBD,
|
||||
mGuiHalfSize = 0x010EDEBE,
|
||||
mFogOfWarOpenEverywhere = 0x010EDEBF,
|
||||
mTrailerMode = 0x010EDEC0,
|
||||
mDayTimeRotationPause = 0x010EDEC1,
|
||||
mPlayerNeverDies = 0x010EDEC2,
|
||||
mFreezeAI = 0x010EDEC3,
|
||||
{_Offset = 0x00F80384, _BuildString = "Build Apr 23 2021 18:40:40", -- Steam dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x010EDEBC)[0] = value end,
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x010EDEBD)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x010EDEBE)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010EDEBF)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x010EDEC0)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010EDEC1)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010EDEC2)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x010EDEC3)[0] = value end,
|
||||
},
|
||||
{_Offset = 0x00F8A7B4, _BuildString = "Build Mar 11 2023 14:05:19", -- Steam dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x010F80EC)[0] = value end,
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x010F80ED)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x010F80EE)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010F80EF)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x010F80F0)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010F80F1)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010F80F2)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x010F80F3)[0] = value end,
|
||||
},
|
||||
{_Offset = 0x00F8A8A4, _BuildString = "Build Jun 19 2023 14:14:52", -- Steam dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x010F810C)[0] = value end,
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x010F810D)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x010F810E)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010F810F)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x010F8110)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010F8111)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010F8112)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x010F8113)[0] = value end,
|
||||
},
|
||||
{_Offset = 0x00F82464, _BuildString = "Build Jul 26 2023 23:06:16", -- Steam dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x010E9A5C)[0] = value end,
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x010E9A5D)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x010E9A5E)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010E9A5F)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x010E9A60)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010E9A61)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010E9A62)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x010E9A63)[0] = value end,
|
||||
},
|
||||
{_Offset = 0x00FA654C, _BuildString = "Build Dec 19 2023 18:34:31", -- Steam dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x011154BC)[0] = value end,
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x011154BD)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x011154BE)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x011154BF)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x011154C0)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x011154C1)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x011154C2)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x011154C3)[0] = value end,
|
||||
},
|
||||
{_Offset = 0x00F8A9DC, _BuildString = "Build Dec 21 2023 00:07:29", -- Steam dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x010F814C)[0] = value end,
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x010F814D)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x010F814E)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010F814F)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x010F8150)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010F8151)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010F8152)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x010F8153)[0] = value end,
|
||||
},
|
||||
{_Offset = 0x00F71DE4, _BuildString = "Build Dec 29 2023 23:36:18", -- Steam dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x0111758C)[0] = value end,
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x0111758D)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x0111758E)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x0111758F)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x01117590)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x01117591)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x01117592)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x01117593)[0] = value end,
|
||||
},
|
||||
{_Offset = 0x00F74FA8, _BuildString = "Build Dec 30 2023 19:37:04", -- Steam dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x0111A5BC)[0] = value end,
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x0111A5BD)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x0111A5BE)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x0111A5BF)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x0111A5C0)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x0111A5C1)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x0111A5C2)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x0111A5C3)[0] = value end,
|
||||
},
|
||||
{_Offset = 0x00F8BA04, _BuildString = "Build Jan 18 2024 12:57:44", -- Steam dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x010F91FC+0)[0] = value end,
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x010F91FC+1)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x010F91FC+2)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010F91FC+3)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x010F91FC+4)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010F91FC+5)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010F91FC+6)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x010F91FC+7)[0] = value end,
|
||||
},
|
||||
{_Offset = 0x0117091C, _BuildString = "Build Feb 2 2024 14:29:06", -- Steam dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x0130585C)[0] = value end,
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x0130585D)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x0130585E)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x0130585F)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x01305860)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x01305861)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x01305862)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x01305863)[0] = value end,
|
||||
},
|
||||
{_Offset = 0x01173F34, _BuildString = "Build Feb 6 2024 15:54:02", -- Steam dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x0130982C+0)[0] = value end,
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x0130982C+1)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x0130982C+2)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x0130982C+3)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x0130982C+4)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x0130982C+5)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x0130982C+6)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x0130982C+7)[0] = value end,
|
||||
},
|
||||
{_Offset = 0x01182F70, _BuildString = "Build Mar 25 2024 17:42:49", -- Steam dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x013198AC+0)[0] = value end,
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x013198AC+1)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x013198AC+2)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x013198AC+3)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x013198AC+4)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x013198AC+5)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x013198AC+6)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x013198AC+7)[0] = value end,
|
||||
},
|
||||
{_Offset = 0x011871FC, _BuildString = "Build Apr 6 2024 20:50:04", -- Steam dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x0131D89C+0)[0] = value end,
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x0131D89C+1)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x0131D89C+2)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x0131D89C+3)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x0131D89C+4)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x0131D89C+5)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x0131D89C+6)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x0131D89C+7)[0] = value end,
|
||||
},
|
||||
{_Offset = 0x0118718C, _BuildString = "Build Apr 8 2024 18:07:16", -- Steam dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x0131D8DC+0)[0] = value end,
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x0131D8DC+1)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x0131D8DC+2)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x0131D8DC+3)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x0131D8DC+4)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x0131D8DC+5)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x0131D8DC+6)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x0131D8DC+7)[0] = value end,
|
||||
},
|
||||
{_Offset = 0x0118FD3C, _BuildString = "Build Aug 12 2024 21:10:13", -- Steam dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x01327D3C+0)[0] = value end,
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x01327D3C+1)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x01327D3C+2)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x01327D3C+3)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x01327D3C+4)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x01327D3C+5)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x01327D3C+6)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x01327D3C+7)[0] = value end,
|
||||
},
|
||||
{_Offset = 0x0118FD3C, _BuildString = "Build Aug 12 2024 21:43:22", -- Steam dev build.
|
||||
mPostFxDisabled = function(value) ffi.cast("char*", 0x01327D3C+0)[0] = value end,
|
||||
mGuiDisabled = function(value) ffi.cast("char*", 0x01327D3C+1)[0] = value end,
|
||||
mGuiHalfSize = function(value) ffi.cast("char*", 0x01327D3C+2)[0] = value end,
|
||||
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x01327D3C+3)[0] = value end,
|
||||
mTrailerMode = function(value) ffi.cast("char*", 0x01327D3C+4)[0] = value end,
|
||||
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x01327D3C+5)[0] = value end,
|
||||
mPlayerNeverDies = function(value) ffi.cast("char*", 0x01327D3C+6)[0] = value end,
|
||||
mFreezeAI = function(value) ffi.cast("char*", 0x01327D3C+7)[0] = value end,
|
||||
},
|
||||
},
|
||||
},
|
||||
[false] = {
|
||||
Windows = {
|
||||
{_Offset = 0x00E1C550, _BuildString = "Build Apr 23 2021 18:44:24", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x0063D8AD) -- Can be found by searching for the pattern C6 80 20 01 00 00 >01< 8B CF E8 FB 1D. The pointer has to point to the highlighted byte.
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x00E22E18, _BuildString = "Build Mar 11 2023 14:09:24", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x006429ED)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x00E22E18, _BuildString = "Build Jun 19 2023 14:18:46", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x006429ED)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x00E146D4, _BuildString = "Build Jul 26 2023 23:10:16", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x0064390D)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x00E333F4, _BuildString = "Build Dec 19 2023 18:38:23", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x00624C5D)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x00E23EC4, _BuildString = "Build Dec 21 2023 00:11:06", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x0064246D)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x00E14FA0, _BuildString = "Build Dec 29 2023 23:40:18", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x00625FFD)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x00E180E8, _BuildString = "Build Dec 30 2023 19:40:49", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x00626EFD)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x00E24E6C, _BuildString = "Build Jan 18 2024 13:01:21", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x00642A47+6)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x00FECE94, _BuildString = "Build Feb 2 2024 14:33:26", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x006AD407)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x00FEEFC0, _BuildString = "Build Feb 6 2024 15:58:22", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x006AD611+6)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x00FF21D8, _BuildString = "Build Feb 9 2024 15:52:49", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x006AE101+6)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x00FF22D4, _BuildString = "Build Feb 12 2024 19:07:19", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x006AE161+6)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x00E23E80, _BuildString = "Build Feb 14 2024 07:46:57", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x006427A7+6)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x00FFDB54, _BuildString = "Build Mar 25 2024 17:48:04", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x006B1FD8+6)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x01001DDC, _BuildString = "Build Apr 6 2024 20:54:23", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x006B35B5+6)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x01001DF4, _BuildString = "Build Apr 8 2024 18:11:27", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x006B3355+6)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x01007CA4, _BuildString = "Build Aug 12 2024 21:14:23", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x006B3925+6)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
{_Offset = 0x01007CA4, _BuildString = "Build Aug 12 2024 21:48:01", -- Steam build.
|
||||
enableModDetection = function(value)
|
||||
local ptr = ffi.cast("char*", 0x006B3925+6)
|
||||
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
|
||||
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
|
||||
end,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -122,24 +411,26 @@ function Modification.SetMemoryOptions(memory)
|
||||
|
||||
-- Look up the tree and set options accordingly.
|
||||
|
||||
local level1 = lookup[DebugGetIsDevBuild()]
|
||||
if level1 == nil then return end
|
||||
local level1 = lookup[DebugAPI.IsDevBuild()]
|
||||
level1 = level1 or {}
|
||||
|
||||
local level2 = level1[ffi.os]
|
||||
if level2 == nil then return end
|
||||
level2 = level2 or {}
|
||||
|
||||
local level3
|
||||
for k, v in pairs(level2) do
|
||||
if ffi.string(ffi.cast("char*", k)) == v._BuildString then
|
||||
local level3 = {}
|
||||
for _, v in ipairs(level2) do
|
||||
if ffi.string(ffi.cast("char*", v._Offset)) == v._BuildString then
|
||||
level3 = v
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
for k, v in pairs(memory) do
|
||||
local address = level3[k]
|
||||
if address ~= nil then
|
||||
ffi.cast("char*", address)[0] = v
|
||||
local modFunc = level3[k]
|
||||
if modFunc ~= nil then
|
||||
modFunc(v)
|
||||
else
|
||||
Message:ShowModificationUnsupported("processMemory", k, v)
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -157,6 +448,13 @@ function Modification.PatchFiles(patches)
|
||||
end
|
||||
ModTextFileSetContent("data/shaders/post_final.frag", postFinal)
|
||||
end
|
||||
if patches.PostFinalReplace then
|
||||
local postFinal = ModTextFileGetContent("data/shaders/post_final.frag")
|
||||
for k, v in pairs(patches.PostFinalReplace) do
|
||||
postFinal = postFinal:gsub(k, v)
|
||||
end
|
||||
ModTextFileSetContent("data/shaders/post_final.frag", postFinal)
|
||||
end
|
||||
end
|
||||
|
||||
---Returns tables with user requested game configuration changes.
|
||||
@ -178,12 +476,16 @@ function Modification.RequiredChanges()
|
||||
config["internal_size_h"] = tostring(Vec2(ModSettingGet("noita-mapcap.internal-resolution")).y)
|
||||
config["backbuffer_width"] = config["window_w"]
|
||||
config["backbuffer_height"] = config["window_h"]
|
||||
config["fullscreen"] = "0"
|
||||
magic["VIRTUAL_RESOLUTION_X"] = tostring(Vec2(ModSettingGet("noita-mapcap.virtual-resolution")).x)
|
||||
magic["VIRTUAL_RESOLUTION_Y"] = tostring(Vec2(ModSettingGet("noita-mapcap.virtual-resolution")).y)
|
||||
-- Set virtual offset to prevent/reduce not correctly drawn pixels at the window border.
|
||||
magic["GRID_RENDER_BORDER"] = "3" -- This will widen the right side of the virtual rectangle. It also shifts the world coordinates to the right.
|
||||
magic["VIRTUAL_RESOLUTION_OFFSET_X"] = "-3"
|
||||
magic["VIRTUAL_RESOLUTION_OFFSET_Y"] = "0"
|
||||
magic["STREAMING_CHUNK_TARGET"] = "16" -- Keep more chunks alive.
|
||||
magic["GRID_MAX_UPDATES_PER_FRAME"] = "1024" -- Allow more pixel physics simulation steps (in 32x32 regions) per frame. With too few, objects can glitch through the terrain/explode.
|
||||
magic["GRID_MIN_UPDATES_PER_FRAME"] = "0" -- Also allow no updates.
|
||||
else
|
||||
-- Reset some values if there is no custom resolution requested.
|
||||
config["internal_size_w"] = "1280"
|
||||
@ -195,13 +497,12 @@ function Modification.RequiredChanges()
|
||||
magic["VIRTUAL_RESOLUTION_OFFSET_Y"] = "-1"
|
||||
end
|
||||
|
||||
-- Always expect a fullscreen mode of 0 (windowed).
|
||||
-- Capturing will not work in fullscreen.
|
||||
config["fullscreen"] = "0"
|
||||
|
||||
-- Also disable screenshake.
|
||||
-- Also disable screen shake.
|
||||
config["screenshake_intensity"] = "0"
|
||||
|
||||
-- And disable the cursor being rendered by Noita itself, which would make it appear in screen captures.
|
||||
config["application_rendered_cursor"] = "0"
|
||||
|
||||
magic["DRAW_PARALLAX_BACKGROUND"] = ModSettingGet("noita-mapcap.disable-background") and "0" or "1"
|
||||
|
||||
-- These magic numbers seem only to work in the dev build.
|
||||
@ -209,6 +510,11 @@ function Modification.RequiredChanges()
|
||||
magic["DEBUG_PAUSE_BOX2D"] = ModSettingGet("noita-mapcap.disable-physics") and "1" or "0"
|
||||
magic["DEBUG_DISABLE_POSTFX_DITHERING"] = ModSettingGet("noita-mapcap.disable-postfx") and "1" or "0"
|
||||
|
||||
-- These magic numbers stop any grid updates (pixel physics), even in the release build.
|
||||
-- But any Box2D objects glitch, therefore the mod option (disable-physics) is disabled and hidden in the non dev build.
|
||||
--magic["GRID_MAX_UPDATES_PER_FRAME"] = ModSettingGet("noita-mapcap.disable-physics") and "0" or "128"
|
||||
--magic["GRID_MIN_UPDATES_PER_FRAME"] = ModSettingGet("noita-mapcap.disable-physics") and "1" or "40"
|
||||
|
||||
if ModSettingGet("noita-mapcap.disable-postfx") then
|
||||
patches.PostFinalConst = {
|
||||
ENABLE_REFRACTION = false,
|
||||
@ -222,13 +528,31 @@ function Modification.RequiredChanges()
|
||||
FOG_FOREGROUND_NIGHT = "vec4(0.0,0.0,0.0,1.0)",
|
||||
FOG_BACKGROUND_NIGHT = "vec3(0.0,0.0,0.0)",
|
||||
}
|
||||
|
||||
-- Disable color grading, which may make the world look a tad more blue when there is freezing/snowing weather.
|
||||
-- This is dependent on the seed and the PC's wall clock, and it only snows in December, January or February.
|
||||
patches.PostFinalReplace = {
|
||||
["color%.rgb = mix%( color, additive_overlay_color%.rgb, additive_overlay_color%.a %);"] = "",
|
||||
["color = mix%(color, vec3%(%(color%.r %+ color%.g %+ color%.b%) %* 0%.3333%), color_grading%.a%);"] = "",
|
||||
["color = color %* color_grading%.rgb;"] = "// Here lies the remains of the tone-mapping/color grading code. 2019-2024. RIP",
|
||||
}
|
||||
end
|
||||
|
||||
if ModSettingGet("noita-mapcap.disable-shaders-gui-ai") then
|
||||
if ModSettingGet("noita-mapcap.disable-shaders-gui-ai") and DebugAPI.IsDevBuild() then
|
||||
memory["mPostFxDisabled"] = 1
|
||||
memory["mGuiDisabled"] = 1
|
||||
memory["mFreezeAI"] = 1
|
||||
memory["mTrailerMode"] = 1 -- Is necessary for chunks to correctly load when DEBUG_PAUSE_GRID_UPDATE is enabled.
|
||||
end
|
||||
|
||||
if DebugAPI.IsDevBuild() and magic["DEBUG_PAUSE_GRID_UPDATE"] == "1" then
|
||||
memory["mTrailerMode"] = 1 -- This is necessary for chunks to correctly load when DEBUG_PAUSE_GRID_UPDATE is enabled.
|
||||
end
|
||||
|
||||
if ModSettingGet("noita-mapcap.disable-mod-detection") and not DebugAPI.IsDevBuild() then
|
||||
memory["enableModDetection"] = 0
|
||||
else
|
||||
-- Don't actively (re)enable mod detection.
|
||||
--memory["enableModDetection"] = 1
|
||||
end
|
||||
|
||||
-- Disables or hides most of the UI.
|
||||
|
@ -8,7 +8,7 @@
|
||||
-----------------------
|
||||
|
||||
-- TODO: Wrap Noita utilities and wrap them into a table: https://stackoverflow.com/questions/9540732/loadfile-without-polluting-global-environment
|
||||
require("utilities") -- Loads Noita's utilities from `data/scripts/lib/utilitites.lua`.
|
||||
require("utilities") -- Loads Noita's utilities from `data/scripts/lib/utilities.lua`.
|
||||
|
||||
--------------------------
|
||||
-- Load library modules --
|
||||
@ -146,7 +146,7 @@ function UI:_DrawMessages(messages)
|
||||
end
|
||||
GuiLayoutEnd(gui)
|
||||
|
||||
if not message.Autoclose then
|
||||
if not message.AutoClose then
|
||||
local clicked = GuiImageButton(gui, self:_GenID(), 5, 0, "", "mods/noita-mapcap/files/ui-gfx/dismiss-8x8.png")
|
||||
--GuiTooltip(gui, "Dismiss message", "")
|
||||
if clicked then messages[key] = nil end
|
||||
@ -196,7 +196,7 @@ function UI:Draw()
|
||||
local gui = self.gui
|
||||
|
||||
-- Skip drawing if we are asked to do so.
|
||||
-- TODO: Find a way to susped UI drawing, but still being able to receive events
|
||||
-- TODO: Find a way to suspend UI drawing, but still being able to receive events
|
||||
if self.suspendFrames and self.suspendFrames > 0 then self.suspendFrames = self.suspendFrames - 1 return end
|
||||
self.suspendFrames = nil
|
||||
|
||||
|
50
go.mod
50
go.mod
@ -1,29 +1,43 @@
|
||||
module github.com/Dadido3/noita-mapcap
|
||||
|
||||
go 1.18
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/cheggaaa/pb/v3 v3.1.0
|
||||
github.com/coreos/go-semver v0.3.0
|
||||
github.com/google/hilbert v0.0.0-20181122061418-320f2e35a565
|
||||
github.com/kbinani/screenshot v0.0.0-20210720154843-7d3a670d8329
|
||||
github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1
|
||||
github.com/Dadido3/go-libwebp v0.3.0
|
||||
github.com/cheggaaa/pb/v3 v3.1.4
|
||||
github.com/coreos/go-semver v0.3.1
|
||||
github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
|
||||
golang.org/x/exp v0.0.0-20220713135740-79cabaa25d75
|
||||
golang.org/x/image v0.0.0-20220617043117-41969df76e82
|
||||
github.com/tdewolff/canvas v0.0.0-20231218015800-2ad5075e9362
|
||||
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1 // indirect
|
||||
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f // indirect
|
||||
github.com/VividCortex/ewma v1.2.0 // indirect
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
|
||||
github.com/fatih/color v1.13.0 // indirect
|
||||
github.com/gen2brain/shm v0.0.0-20200228170931-49f9650110c5 // indirect
|
||||
github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240 // indirect
|
||||
github.com/benoitkugler/textlayout v0.3.0 // indirect
|
||||
github.com/benoitkugler/textprocessing v0.0.3 // indirect
|
||||
github.com/chzyer/readline v1.5.1 // indirect
|
||||
github.com/dsnet/compress v0.0.1 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/gen2brain/shm v0.1.0 // indirect
|
||||
github.com/go-fonts/latin-modern v0.3.2 // indirect
|
||||
github.com/go-fonts/liberation v0.3.2 // indirect
|
||||
github.com/go-latex/latex v0.0.0-20231108140139-5c1ce85aa4ea // indirect
|
||||
github.com/go-text/typesetting v0.0.0-20231219150831-cc0073efdbb4 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/jezek/xgb v1.1.1 // indirect
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.13 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/tdewolff/minify/v2 v2.20.10 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.7 // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
golang.org/x/net v0.23.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
star-tex.org/x/tex v0.4.0 // indirect
|
||||
)
|
||||
|
142
go.sum
142
go.sum
@ -1,59 +1,117 @@
|
||||
git.sr.ht/~sbinet/gg v0.5.0 h1:6V43j30HM623V329xA9Ntq+WJrMjDxRjuAB1LFWF5m8=
|
||||
git.sr.ht/~sbinet/gg v0.5.0/go.mod h1:G2C0eRESqlKhS7ErsNey6HHrqU1PwsnCQlekFi9Q2Oo=
|
||||
github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1 h1:LejjvYg4tCW5HO7q/1nzPrprh47oUD9OUySQ29pDp5c=
|
||||
github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1/go.mod h1:cnC/60IoLiDM0GhdKYJ6oO7AwpZe1IQfPnSKlAURgHw=
|
||||
github.com/VividCortex/ewma v1.1.1/go.mod h1:2Tkkvm3sRDVXaiyucHiACn4cqf7DpdyLvmxzcbUokwA=
|
||||
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f h1:l7moT9o/v/9acCWA64Yz/HDLqjcRTvc0noQACi4MsJw=
|
||||
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f/go.mod h1:vIOkSdX3NDCPwgu8FIuTat2zDF0FPXXQ0RYFRy+oQic=
|
||||
github.com/Dadido3/go-libwebp v0.3.0 h1:Qr3Gt8Kn4qgemezDVnjAJffMB9C0QJhxP+9u0U5mC94=
|
||||
github.com/Dadido3/go-libwebp v0.3.0/go.mod h1:rYiWwlI58XRSMUFMw23nMezErbjX3Z5Xv0Kk3w6Mwwo=
|
||||
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
|
||||
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
|
||||
github.com/cheggaaa/pb/v3 v3.1.0 h1:3uouEsl32RL7gTiQsuaXD4Bzbfl5tGztXGUvXbs4O04=
|
||||
github.com/cheggaaa/pb/v3 v3.1.0/go.mod h1:YjrevcBqadFDaGQKRdmZxTY42pXEqda48Ea3lt0K/BE=
|
||||
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
|
||||
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
|
||||
github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE=
|
||||
github.com/benoitkugler/textlayout v0.3.0 h1:2ehWXEkgb6RUokTjXh1LzdGwG4dRP6X3dqhYYDYhUVk=
|
||||
github.com/benoitkugler/textlayout v0.3.0/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w=
|
||||
github.com/benoitkugler/textlayout-testdata v0.1.1/go.mod h1:i/qZl09BbUOtd7Bu/W1CAubRwTWrEXWq6JwMkw8wYxo=
|
||||
github.com/benoitkugler/textprocessing v0.0.3 h1:Q2X+Z6vxuW5Bxn1R9RaNt0qcprBfpc2hEUDeTlz90Ng=
|
||||
github.com/benoitkugler/textprocessing v0.0.3/go.mod h1:/4bLyCf1QYywunMK3Gf89Nhb50YI/9POewqrLxWhxd4=
|
||||
github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY=
|
||||
github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8=
|
||||
github.com/cheggaaa/pb/v3 v3.1.4 h1:DN8j4TVVdKu3WxVwcRKu0sG00IIU6FewoABZzXbRQeo=
|
||||
github.com/cheggaaa/pb/v3 v3.1.4/go.mod h1:6wVjILNBaXMs8c21qRiaUM8BR82erfgau1DQ4iUXmSA=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
|
||||
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
||||
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
|
||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
|
||||
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
|
||||
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||
github.com/gen2brain/shm v0.0.0-20200228170931-49f9650110c5 h1:Y5Q2mEwfzjMt5+3u70Gtw93ZOu2UuPeeeTBDntF7FoY=
|
||||
github.com/gen2brain/shm v0.0.0-20200228170931-49f9650110c5/go.mod h1:uF6rMu/1nvu+5DpiRLwusA6xB8zlkNoGzKn8lmYONUo=
|
||||
github.com/google/hilbert v0.0.0-20181122061418-320f2e35a565 h1:KBAlCAY6eLC44FiEwbzEbHnpVlw15iVM4ZK8QpRIp4U=
|
||||
github.com/google/hilbert v0.0.0-20181122061418-320f2e35a565/go.mod h1:xn6EodFfRzV6j8NXQRPjngeHWlrpOrsZPKuuLRThU1k=
|
||||
github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240 h1:dy+DS31tGEGCsZzB45HmJJNHjur8GDgtRNX9U7HnSX4=
|
||||
github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240/go.mod h1:3P4UH/k22rXyHIJD2w4h2XMqPX4Of/eySEZq9L6wqc4=
|
||||
github.com/kbinani/screenshot v0.0.0-20210720154843-7d3a670d8329 h1:qq2nCpSrXrmvDGRxW0ruW9BVEV1CN2a9YDOExdt+U0o=
|
||||
github.com/kbinani/screenshot v0.0.0-20210720154843-7d3a670d8329/go.mod h1:2VPVQDR4wO7KXHwP+DAypEy67rXf+okUx2zjgpCxZw4=
|
||||
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
|
||||
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
|
||||
github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q=
|
||||
github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo=
|
||||
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY=
|
||||
github.com/gen2brain/shm v0.1.0/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA=
|
||||
github.com/go-fonts/latin-modern v0.3.2 h1:M+Sq24Dp0ZRPf3TctPnG1MZxRblqyWC/cRUL9WmdaFc=
|
||||
github.com/go-fonts/latin-modern v0.3.2/go.mod h1:9odJt4NbRrbdj4UAMuLVd4zEukf6aAEKnDaQga0whqQ=
|
||||
github.com/go-fonts/liberation v0.3.2 h1:XuwG0vGHFBPRRI8Qwbi5tIvR3cku9LUfZGq/Ar16wlQ=
|
||||
github.com/go-fonts/liberation v0.3.2/go.mod h1:N0QsDLVUQPy3UYg9XAc3Uh3UDMp2Z7M1o4+X98dXkmI=
|
||||
github.com/go-latex/latex v0.0.0-20231108140139-5c1ce85aa4ea h1:DfZQkvEbdmOe+JK2TMtBM+0I9GSdzE2y/L1/AmD8xKc=
|
||||
github.com/go-latex/latex v0.0.0-20231108140139-5c1ce85aa4ea/go.mod h1:Y7Vld91/HRbTBm7JwoI7HejdDB0u+e9AUBO9MB7yuZk=
|
||||
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
|
||||
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
|
||||
github.com/go-text/typesetting v0.0.0-20231219150831-cc0073efdbb4 h1:MKnPksPov832ct2c9a40QUB+2lgf2pBo7N92TxAAFA8=
|
||||
github.com/go-text/typesetting v0.0.0-20231219150831-cc0073efdbb4/go.mod h1:MrLApvxyzSW0MhQqLc484jkUWYX4wsEvEqDosB5Io80=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20231204162240-fa4dc564ba79 h1:3yBOzx29wog0i7TnUBMcp90EwIb+A5kqmr5vny1UOm8=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20231204162240-fa4dc564ba79/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
|
||||
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
|
||||
github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237 h1:YOp8St+CM/AQ9Vp4XYm4272E77MptJDHkwypQHIRl9Q=
|
||||
github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237/go.mod h1:e7qQlOY68wOz4b82D7n+DdaptZAi+SHW0+yKiWZzEYE=
|
||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
golang.org/x/exp v0.0.0-20220713135740-79cabaa25d75 h1:x03zeu7B2B11ySp+daztnwM5oBJ/8wGUSqrwcw9L0RA=
|
||||
golang.org/x/exp v0.0.0-20220713135740-79cabaa25d75/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
|
||||
golang.org/x/image v0.0.0-20220617043117-41969df76e82 h1:KpZB5pUSBvrHltNEdK/tw0xlPeD13M6M6aGP32gKqiw=
|
||||
golang.org/x/image v0.0.0-20220617043117-41969df76e82/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/tdewolff/canvas v0.0.0-20231218015800-2ad5075e9362 h1:E9HkFtZcjoZQCaSyb2Finw4jhC0NWOJ2DCCoAMYrXLg=
|
||||
github.com/tdewolff/canvas v0.0.0-20231218015800-2ad5075e9362/go.mod h1:hGxWCl1a3KdYh6pxYy9sa9jLAlmKLMeuCSCjjy39iVE=
|
||||
github.com/tdewolff/minify/v2 v2.20.10 h1:iz9IkdRqD2pyneib/AvTas23RRG5TnuUFNcNVKmL/jU=
|
||||
github.com/tdewolff/minify/v2 v2.20.10/go.mod h1:xSJ9fXIfyuEMex88JT4jl8GvXnl/RzWNdqD96AqKlX0=
|
||||
github.com/tdewolff/parse/v2 v2.7.7 h1:V+50eFDH7Piw4IBwH8D8FtYeYbZp3T4SCtIvmBSIMyc=
|
||||
github.com/tdewolff/parse/v2 v2.7.7/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
|
||||
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
|
||||
github.com/tdewolff/test v1.0.11-0.20231121141655-2d5236e10ae4 h1:CmTImZFElFD07EUPqgMEraDMnJX1E5oJKeibjg0SC2c=
|
||||
github.com/tdewolff/test v1.0.11-0.20231121141655-2d5236e10ae4/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=
|
||||
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 h1:+iq7lrkxmFNBM7xx+Rae2W6uyPfhPeDWD+n+JgppptE=
|
||||
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
|
||||
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gonum.org/v1/plot v0.14.0 h1:+LBDVFYwFe4LHhdP8coW6296MBEY4nQ+Y4vuUpJopcE=
|
||||
gonum.org/v1/plot v0.14.0/go.mod h1:MLdR9424SJed+5VqC6MsouEpig9pZX2VZ57H9ko2bXU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
star-tex.org/x/tex v0.4.0 h1:AXUwgpnHLCxZUWW3qrmjv6ezNhH3PjUVBuLLejz2cgU=
|
||||
star-tex.org/x/tex v0.4.0/go.mod h1:w91ycsU/DkkCr7GWr60GPWqp3gn2U+6VX71T0o8k8qE=
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 526 KiB |
Binary file not shown.
Before Width: | Height: | Size: 292 KiB |
BIN
images/title.png
Normal file
BIN
images/title.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.0 MiB |
6
init.lua
6
init.lua
@ -1,4 +1,4 @@
|
||||
-- Copyright (c) 2022 David Vogel
|
||||
-- Copyright (c) 2022-2024 David Vogel
|
||||
--
|
||||
-- This software is released under the MIT License.
|
||||
-- https://opensource.org/licenses/MIT
|
||||
@ -108,7 +108,6 @@ end
|
||||
---Called *every* time the game is about to start updating the world.
|
||||
function OnWorldPreUpdate()
|
||||
Message:CatchException("OnWorldPreUpdate", function()
|
||||
|
||||
-- Coroutines aren't run every frame in this lua sandbox, do it manually here.
|
||||
wake_up_waiting_threads(1)
|
||||
|
||||
@ -150,10 +149,13 @@ end
|
||||
---@param isPaused boolean
|
||||
---@param isInventoryPause boolean
|
||||
function OnPausedChanged(isPaused, isInventoryPause)
|
||||
Message:CatchException("OnPausedChanged", function()
|
||||
-- Set some stuff based on mod settings.
|
||||
-- Normally this would be in `OnModSettingsChanged`, but that doesn't seem to be called.
|
||||
local config, magic, memory, patches = Modification.RequiredChanges()
|
||||
Modification.SetMemoryOptions(memory)
|
||||
|
||||
end)
|
||||
end
|
||||
|
||||
---Will be called when the game is unpaused, if player changed any mod settings while the game was paused.
|
||||
|
2
scripts/dist/compress.go
vendored
2
scripts/dist/compress.go
vendored
@ -62,7 +62,7 @@ func addPathToZip(zipWriter *zip.Writer, srcPath, archiveBasePath string, ignore
|
||||
return err
|
||||
}
|
||||
|
||||
header.Name = archivePath
|
||||
header.Name = filepath.ToSlash(archivePath)
|
||||
header.Method = zip.Deflate
|
||||
|
||||
writer, err := zipWriter.CreateHeader(header)
|
||||
|
32
settings.lua
32
settings.lua
@ -1,4 +1,4 @@
|
||||
-- Copyright (c) 2022 David Vogel
|
||||
-- Copyright (c) 2022-2024 David Vogel
|
||||
--
|
||||
-- This software is released under the MIT License.
|
||||
-- https://opensource.org/licenses/MIT
|
||||
@ -67,15 +67,15 @@ modSettings = {
|
||||
{
|
||||
id = "capture-mode",
|
||||
ui_name = "Mode",
|
||||
ui_description = "How the mod captures:\n- Live: Capture as you play along.\n- Area: Capture a defined area of the world.\n- Spiral: Capture in a spiral around a starting point indefinitely.",
|
||||
ui_description = "How the mod captures:\n- Live: Capture as you play along.\n- Area: Capture a defined area of the world.\n- Spiral: Capture in a spiral around a starting point indefinitely.\n- Animation: Capture the screen frame by frame.",
|
||||
value_default = "live",
|
||||
values = { { "live", "Live" }, { "area", "Area" }, { "spiral", "Spiral" } },
|
||||
values = { { "live", "Live" }, { "area", "Area" }, { "spiral", "Spiral" }, { "animation", "Animation"} },
|
||||
scope = MOD_SETTING_SCOPE_RUNTIME,
|
||||
},
|
||||
{
|
||||
id = "capture-mode-spiral-origin",
|
||||
ui_name = " Origin",
|
||||
ui_description = "The starting point or center of the spiral.\n- Current position: Your ingame position.\n- World origin: Near the cave entrance.\n- Custom position: Enter your own coordinates.",
|
||||
ui_description = "The starting point or center of the spiral.\n- Current position: Your in-game position.\n- World origin: Near the cave entrance.\n- Custom position: Enter your own coordinates.",
|
||||
value_default = "current",
|
||||
values = { { "current", "Current position" }, { "0", "World origin" }, { "custom", "Custom position" } },
|
||||
scope = MOD_SETTING_SCOPE_RUNTIME,
|
||||
@ -95,7 +95,7 @@ modSettings = {
|
||||
ui_name = " Rectangle",
|
||||
ui_description = "The area to be captured.\nSee documentation for more information.",
|
||||
value_default = "1x1",
|
||||
values = { { "1x1", "Base layout" }, { "1x3", "Main World" }, { "1.5x3", "Extended" }, { "custom", "Custom" } },
|
||||
values = { { "1x1", "Base layout" }, { "1x3", "Main World" }, { "1x3 -1", "-1 Parallel World" }, { "1x3 +1", "+1 Parallel World" }, { "1.5x3", "Extended" }, { "3x3", "3 Worlds" }, { "custom", "Custom" } },
|
||||
scope = MOD_SETTING_SCOPE_RUNTIME,
|
||||
show_fn = function() return modSettings:GetNextValue("capture-mode") == "area" end,
|
||||
},
|
||||
@ -143,7 +143,7 @@ modSettings = {
|
||||
value_default = "512",
|
||||
allowed_characters = "0123456789",
|
||||
scope = MOD_SETTING_SCOPE_RUNTIME,
|
||||
show_fn = function() return modSettings:GetNextValue("capture-mode") ~= "live" end,
|
||||
show_fn = function() return modSettings:GetNextValue("capture-mode") == "area" or modSettings:GetNextValue("capture-mode") == "spiral" end,
|
||||
},
|
||||
{
|
||||
id = "pixel-scale",
|
||||
@ -157,6 +157,18 @@ modSettings = {
|
||||
scope = MOD_SETTING_SCOPE_RUNTIME,
|
||||
change_fn = roundChange,
|
||||
},
|
||||
{
|
||||
id = "capture-delay",
|
||||
ui_name = "Capture delay",
|
||||
ui_description = "Additional delay before a screen capture is taken.\nThis can help the world to be populated, and the physics simulation to settle down.\nA setting of 0 means that the screenshot is taken as soon as possible.\n \nUse a value of 10 for a good result without slowing the capture process down too much.",
|
||||
value_default = 0,
|
||||
value_min = 0,
|
||||
value_max = 60,
|
||||
value_display_multiplier = 1,
|
||||
value_display_formatting = " $0 frames",
|
||||
scope = MOD_SETTING_SCOPE_RUNTIME,
|
||||
show_fn = function() return modSettings:GetNextValue("capture-mode") == "area" or modSettings:GetNextValue("capture-mode") == "spiral" end,
|
||||
},
|
||||
{
|
||||
id = "custom-resolution-live",
|
||||
ui_name = "Use custom resolution",
|
||||
@ -314,6 +326,14 @@ modSettings = {
|
||||
value_default = false,
|
||||
scope = MOD_SETTING_SCOPE_RUNTIME,
|
||||
},
|
||||
{
|
||||
id = "disable-mod-detection",
|
||||
ui_name = " Disable mod detection",
|
||||
ui_description = "If enabled, Noita will behave as if no mods are enabled.\nTherefore secrets like the cauldron will be generated.",
|
||||
hidden = DebugAPI.IsDevBuild(),
|
||||
value_default = false,
|
||||
scope = MOD_SETTING_SCOPE_RUNTIME,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user