Compare commits

..

No commits in common. "master" and "v2.0.1" have entirely different histories.

58 changed files with 1250 additions and 3603 deletions

13
.github/FUNDING.yml vendored
View File

@ -1,13 +0,0 @@
# 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']

View File

@ -8,17 +8,18 @@ jobs:
build: build:
name: Build and release name: Build and release
runs-on: windows-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
goos: [windows] goos: [windows]
goarch: ["amd64"] goarch: ["amd64"]
steps: steps:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v2 uses: actions/setup-go@v2
with: with:
go-version: ^1.22 go-version: ^1.18
- name: Check out code into the Go module directory - name: Check out code into the Go module directory
uses: actions/checkout@v2 uses: actions/checkout@v2
@ -31,7 +32,6 @@ jobs:
env: env:
GOARCH: ${{ matrix.goarch }} GOARCH: ${{ matrix.goarch }}
GOOS: ${{ matrix.goos }} GOOS: ${{ matrix.goos }}
CGO_ENABLED: 1
- name: Create distribution archive - name: Create distribution archive
run: go run -v ./scripts/dist run: go run -v ./scripts/dist

View File

@ -12,7 +12,7 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v2 uses: actions/setup-go@v2
with: with:
go-version: ^1.22 go-version: ^1.18
- name: Check out code into the Go module directory - name: Check out code into the Go module directory
uses: actions/checkout@v2 uses: actions/checkout@v2

6
.gitignore vendored
View File

@ -105,9 +105,5 @@ $RECYCLE.BIN/
/output/ /output/
/dist/ /dist/
/bin/stitch/*.png /bin/stitch/output.png
/bin/stitch/*.dzi
/bin/stitch/*_files/
/files/magic-numbers/generated.xml /files/magic-numbers/generated.xml
/bin/stitch/captures/*

86
.vscode/settings.json vendored
View File

@ -1,121 +1,41 @@
{ {
"cSpell.words": [ "cSpell.words": [
"aabb",
"acidflow",
"appdata",
"autosetup",
"backbuffer", "backbuffer",
"basicfont", "basicfont",
"bytecode",
"cheggaaa", "cheggaaa",
"Dadido",
"dofile", "dofile",
"dont",
"Downscales", "Downscales",
"downscaling", "downscaling",
"DPMM",
"executables", "executables",
"framebuffer",
"framebuffers",
"Fullscreen", "Fullscreen",
"goarch", "goarch",
"gridify", "gridify",
"hacky", "hacky",
"hilbertify", "hilbertify",
"Hitbox",
"ipairs", "ipairs",
"kbinani", "kbinani",
"Lanczos", "Lanczos",
"lann",
"ldflags", "ldflags",
"libwebp",
"linearize",
"longleg",
"lowram", "lowram",
"luanxml",
"manifoldco", "manifoldco",
"mapcap", "mapcap",
"Metamethods",
"metaobject",
"Metatable",
"nfnt", "nfnt",
"Niccoli",
"noita", "noita",
"noitamap", "prerender",
"Nolla",
"NXML",
"openseadragon",
"pixelated",
"polymorphed",
"promptui", "promptui",
"rasterizer",
"Regen",
"respawn",
"runfast",
"savegames", "savegames",
"schollz", "schollz",
"screenshake",
"svenstaro", "svenstaro",
"tcnksm", "tcnksm",
"tdewolff",
"unmodded",
"unstitchable",
"upscaled",
"Vogel", "Vogel",
"Voronoi",
"webp",
"wepb",
"xmax", "xmax",
"xmin", "xmin",
"ymax", "ymax",
"ymin", "ymin"
"Zatherz"
], ],
"Lua.runtime.version": "LuaJIT", "Lua.runtime.version": "LuaJIT",
"Lua.format.defaultConfig": { "Lua.format.defaultConfig": {
"max_line_length": "512" "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"
]
} }

View File

@ -1,10 +1,6 @@
# Capture areas # Capture areas
A list of available 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. Coordinates are in in-game "virtual" or "world" pixels.
`Right` and `Bottom` coordinates are not included in the rectangle. `Right` and `Bottom` coordinates are not included in the rectangle.
@ -68,16 +64,3 @@ Bottom = 41984
The end result will have a size of `51200 x 73728 pixels ~= 3775 megapixels`. The end result will have a size of `51200 x 73728 pixels ~= 3775 megapixels`.
![Base layout](images/scale32_extended.png) ![Base layout](images/scale32_extended.png)
## `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`.

View File

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2019-2023 David Vogel Copyright (c) 2019-2022 David Vogel
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@ -1,14 +1,11 @@
# Noita map capture addon # Noita map capture addon [![Build Status](https://travis-ci.com/Dadido3/noita-mapcap.svg?branch=master)](https://travis-ci.com/Dadido3/noita-mapcap)
A mod for Noita that can capture images of the world and stitch them into one large image. A mod for Noita that can capture images of the world and stitch them into one large image.
It works with the regular Noita build and the dev build. It works with the regular Noita and the dev build.
![Title image](images/title.png) ![missing image](images/example2.png)
Map captures created with this mod can be viewed on [map.runfast.stream] (may contain spoilers). A resulting image with nearly 3.8 gigapixels can be [seen here](https://easyzoom.com/image/223556) (Warning: 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 ## System requirements
@ -48,7 +45,7 @@ All you need to do is follow the given instructions, like:
> >
> You can always *right* click ![Record button](files/ui-gfx/record-16x16.png) to reset the above mentioned settings back to Noita's default. > You can always *right* click ![Record button](files/ui-gfx/record-16x16.png) to reset the above mentioned settings back to Noita's default.
After all issues have been resolved you are free to start capturing. After all issue have been resolved you are free to start capturing.
To the top left of the window are 3 buttons: To the top left of the window are 3 buttons:
@ -56,18 +53,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. You can always restart a capture, and it will resume where it was stopped.
- ![Output directory button](files/ui-gfx/open-output-16x16.png) Reveals the output directory in your file browser. - ![Output directory button](files/ui-gfx/open-output-16x16.png) Reveals the output directory in your file browser.
This will contain raw screenshots and other recorded data that later can be stitched. This will contain raw screenshots that later can be stitched.
- ![Stitch button](files/ui-gfx/stitch-16x16.png) Reveals the stitching tool directory in your file browser. - ![Stitch button](files/ui-gfx/stitch-16x16.png) Reveals the directory of the stitch tool in your file browser.
To stitch the final result, click ![Stitch button](files/ui-gfx/stitch-16x16.png) to open the directory of the stitching tool. To stitch the final result, click ![Stitch button](files/ui-gfx/stitch-16x16.png) to open the directory of the stitching tool.
Start `stitch.exe` and proceed with the default values. Start `stitch.exe` and proceed with the default values.
After a few minutes the file `output.png` will be created. After a few minutes the file `output.png` will be created.
> ![Hint](files/ui-gfx/hint-16x16.png) See [stitcher/README.md](bin/stitch/README.md) for more information about all stitcher parameters.
## Mod settings ## Mod settings
> ![Hint](files/ui-gfx/hint-16x16.png) Use *right* mouse button to reset any mod setting to their default. > ![Hint](files/ui-gfx/hint-16x16.png) Use *right* mouse button to reset any mod setting to their default.
- `Mode`: Defines what the mod captures, and how it captures it: - `Mode`: Defines what the mod captures, and how it captures it:
@ -81,10 +78,6 @@ After a few minutes the file `output.png` will be created.
- `Spiral`: Will capture the world in a spiral. - `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. 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 ### Advanced mod settings
- `World seed`: If non empty, this will set the next new game to this seed. - `World seed`: If non empty, this will set the next new game to this seed.
@ -123,7 +116,7 @@ After a few minutes the file `output.png` will be created.
- Glow - Glow
- Gamma correction - 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. Only works in dev build.
- `Disable entity logic`: Will modify all encountered entities: - `Disable entity logic`: Will modify all encountered entities:
@ -136,12 +129,12 @@ After a few minutes the file `output.png` will be created.
### Example settings ### Example settings
Use these settings if you want to capture your in-game action. Use this for capturing while you are playing the game.
The sliders are at their default values: The sliders are at their default values:
![Live capture example settings](images/mod-settings-live.png) ![Live capture example settings](images/mod-settings-live.png)
Use these settings to capture the [base layout](AREAS.md#base-layout) with the least amount of glitches and artifacts. Use this to capture the [base layout](AREAS.md#base-layout) with the least amount of glitches and artifacts.
The sliders are at their default values: The sliders are at their default values:
![Area capture example settings](images/mod-settings-area.png) ![Area capture example settings](images/mod-settings-area.png)
@ -152,9 +145,8 @@ The sliders are at their default values:
There is not a lot you can do about it: 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] or a similar tool. - 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.~~ 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. - You can disable the replay recorder.
@ -166,7 +158,7 @@ These can't really be prevented.
All you can do is to click `Ignore always`. 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. 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, the mod works just as well as in the dev build. With the exception that you can't disable the pixel and rigid body simulations it works as good as in the dev build.
### The mod messed up my game ### The mod messed up my game
@ -179,45 +171,22 @@ To reset any permanent settings that may have been set by the mod:
2. Start a new game. 2. Start a new game.
3. *Right* click ![Record button](files/ui-gfx/record-16x16.png) and follow instructions. 3. *Right* click ![Record button](files/ui-gfx/record-16x16.png) and follow instructions.
> ![Hint](files/ui-gfx/hint-16x16.png) 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: Alternatively, you can reset **all** game settings by deleting:
- `"%appdata%\..\LocalLow\Nolla_Games_Noita\save_shared\config.xml"` for the regular Noita. - `"%appdata%\..\LocalLow\Nolla_Games_Noita\save_shared\config.xml"` for the regular Noita.
- `"...\Noita\save_shared\config.xml"` for the dev build. - `"...\Noita\save_shared\config.xml"` for the dev build.
### The objects in the stitched image are blurry ## Additional information
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. 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 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 ## Acknowledgements
This mod uses the [LuaNXML] library by [Zatherz]. This mod uses the [LuaNXML](https://github.com/zatherz/luanxml) library by [Zatherz](https://github.com/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 ## License
[MIT](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

View File

@ -1,10 +1,8 @@
; Copyright (c) 2019-2024 David Vogel ; Copyright (c) 2019-2022 David Vogel
; ;
; This software is released under the MIT License. ; This software is released under the MIT License.
; https://opensource.org/licenses/MIT ; https://opensource.org/licenses/MIT
EnableExplicit
UsePNGImageEncoder() UsePNGImageEncoder()
Declare Worker(*Dummy) Declare Worker(*Dummy)
@ -17,43 +15,44 @@ Structure QueueElement
sy.i sy.i
EndStructure EndStructure
Structure GLViewportDims ; Source: https://www.purebasic.fr/english/viewtopic.php?f=13&t=29981&start=15
x.i Procedure EnumWindowsProc(hWnd.l, *lParam.Long)
y.i Protected lpProc.l
width.i GetWindowThreadProcessId_(hWnd, @lpProc)
height.i If *lParam\l = lpProc ; Check if current window's processID matches
EndStructure *lParam\l = hWnd ; Replace processID in the param With the hwnd As result
ProcedureReturn #False ; Return false to stop iterating
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 EndIf
glGetIntegerv_(#GL_VIEWPORT, *dims)
ProcedureReturn #True ProcedureReturn #True
EndProcedure EndProcedure
; Returns the size of the main OpenGL rendering output as a windows RECT. ; 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
ProcedureDLL GetRect(*rect.RECT) ProcedureDLL GetRect(*rect.RECT)
Protected hWnd.l = GetProcHwnd()
If Not hWnd
ProcedureReturn #False
EndIf
If Not *rect If Not *rect
ProcedureReturn #False ProcedureReturn #False
EndIf EndIf
Protected dims.GLViewportDims GetClientRect_(hWnd, *rect)
glGetIntegerv_(#GL_VIEWPORT, dims)
*rect\left = dims\x ; A RECT consists basically of two POINT structures
*rect\top = dims\y ClientToScreen_(hWnd, @*rect\left)
*rect\right = dims\x + dims\width ClientToScreen_(hWnd, @*rect\Right)
*rect\bottom = dims\y + dims\height
ProcedureReturn #True ProcedureReturn #True
EndProcedure EndProcedure
@ -65,16 +64,13 @@ ProcedureDLL AttachProcess(Instance)
CreateDirectory("mods/noita-mapcap/output/") CreateDirectory("mods/noita-mapcap/output/")
Static Dim WorkerInfos.WorkerInfo(#Workers-1) For i = 1 To 6
Protected i CreateThread(@Worker(), #Null)
For i = 0 To #Workers-1
WorkerInfos(i)\workerNumber = i
CreateThread(@Worker(), @WorkerInfos(i))
Next Next
EndProcedure EndProcedure
Procedure Worker(*workerInfo.WorkerInfo) Procedure Worker(*Dummy)
Protected img, x, y, sx, sy Protected img, x, y
Repeat Repeat
WaitSemaphore(Semaphore) WaitSemaphore(Semaphore)
@ -93,61 +89,62 @@ Procedure Worker(*workerInfo.WorkerInfo)
ResizeImage(img, sx, sy) ResizeImage(img, sx, sy)
EndIf EndIf
; Save image temporary, and only move it once it's fully exported. SaveImage(img, "mods/noita-mapcap/output/" + x + "," + y + ".png", #PB_ImagePlugin_PNG)
; This prevents images getting corrupted when the main process crashes. ;SaveImage(img, "" + x + "," + y + ".png", #PB_ImagePlugin_PNG) ; Test
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) FreeImage(img)
ForEver ForEver
EndProcedure EndProcedure
; Takes a screenshot of the client area of this process' active window. ; 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 viewport coordinates. ; The portion of the client area that is captured is described by capRect, which is in window coordinates and relative to the client area.
; 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. ; 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. ; 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) ProcedureDLL Capture(*capRect.RECT, x.l, y.l, sx.l, sy.l)
Protected viewportRect.RECT Protected hWnd.l = GetProcHwnd()
If Not GetRect(@viewportRect) If Not hWnd
ProcedureReturn #False ProcedureReturn #False
EndIf EndIf
Protected imageID, hDC, *pixelBuffer Protected rect.RECT
If Not GetRect(@rect)
ProcedureReturn #False
EndIf
; Limit the desired capture area to the actual client area of the viewport. ; Limit the desired capture area to the actual client area of the window.
If *capRect\left < 0 : *capRect\left = 0 : EndIf 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\top < 0 : *capRect\top = 0 : EndIf
If *capRect\right < *capRect\left : *capRect\right = *capRect\left : EndIf If *capRect\bottom > rect\bottom-rect\top : *capRect\bottom = rect\bottom-rect\top : 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
Protected capWidth = *capRect\right - *capRect\left imageID = CreateImage(#PB_Any, *capRect\right-*capRect\left, *capRect\bottom-*capRect\top)
Protected capHeight = *capRect\bottom - *capRect\top
imageID = CreateImage(#PB_Any, capWidth, capHeight)
If Not imageID If Not imageID
ProcedureReturn #False ProcedureReturn #False
EndIf EndIf
; Get DC of window.
windowDC = GetDC_(hWnd)
If Not windowDC
FreeImage(imageID)
ProcedureReturn #False
EndIf
hDC = StartDrawing(ImageOutput(imageID)) hDC = StartDrawing(ImageOutput(imageID))
If Not hDC If Not hDC
ReleaseDC_(hWnd, windowDC)
FreeImage(imageID) FreeImage(imageID)
ProcedureReturn #False ProcedureReturn #False
EndIf 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.
*pixelBuffer = DrawingBuffer()
glReadPixels_(*capRect\left, *capRect\top, capWidth, capHeight, #GL_BGR_EXT, #GL_UNSIGNED_BYTE, *pixelBuffer)
If glGetError_() <> #GL_NO_ERROR
StopDrawing() StopDrawing()
ReleaseDC_(hWnd, windowDC)
FreeImage(imageID) FreeImage(imageID)
ProcedureReturn #False ProcedureReturn #False
EndIf EndIf
StopDrawing() StopDrawing()
ReleaseDC_(hWnd, windowDC)
LockMutex(Mutex) LockMutex(Mutex)
; Check if the queue has too many elements, if so, wait. (Emulate go's channels) ; Check if the queue has too many elements, if so, wait. (Emulate go's channels)
While ListSize(Queue()) > 1 While ListSize(Queue()) > 1
@ -176,14 +173,13 @@ EndProcedure
;Capture(123, 123) ;Capture(123, 123)
;Delay(1000) ;Delay(1000)
; IDE Options = PureBasic 6.04 LTS (Windows - x64) ; IDE Options = PureBasic 6.00 LTS (Windows - x64)
; ExecutableFormat = Shared dll ; ExecutableFormat = Shared dll
; CursorPosition = 99 ; CursorPosition = 94
; FirstLine = 72 ; FirstLine = 39
; Folding = - ; Folding = --
; Optimizer ; Optimizer
; EnableThread ; EnableThread
; EnableXP ; EnableXP
; Executable = capture.dll ; Executable = capture.dll
; DisableDebugger ; Compiler = PureBasic 6.00 LTS (Windows - x86)
; Compiler = PureBasic 6.04 LTS - C Backend (Windows - x86)

Binary file not shown.

1
bin/stitch/Profile.bat Normal file
View File

@ -0,0 +1 @@
go tool pprof -http=: ./stitch.exe cpu.prof

View File

@ -17,7 +17,7 @@ The source images need to contain their coordinates in the filename, as this pro
example list of files: example list of files:
``` Text ``` Shell Session
0,0.png 0,0.png
512,0.png 512,0.png
-512,0.png -512,0.png
@ -29,27 +29,11 @@ example list of files:
- Either run the program and follow the interactive prompt. - Either run the program and follow the interactive prompt.
- Or run the program with parameters: - Or run the program with parameters:
- `divide int` - `divide int`
A downscaling factor. 2 will produce an image with half the side lengths. Defaults to 1. A downscaling factor. 2 will produce an image with half the side lengths. (default 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` - `input string`
The source path of the image tiles to be stitched. Defaults to "./..//..//output" The source path of the image tiles to be stitched. (default "..\\..\\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` - `output string`
The path and filename of the resulting stitched image. Defaults to "output.png". The path and filename of the resulting stitched image. (default "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` - `xmax int`
Right bound of the output rectangle. This coordinate is not included in the output. Right bound of the output rectangle. This coordinate is not included in the output.
- `xmin int` - `xmin int`
@ -58,6 +42,10 @@ example list of files:
Lower bound of the output rectangle. This coordinate is not included in the output. Lower bound of the output rectangle. This coordinate is not included in the output.
- `ymin int` - `ymin int`
Upper bound of the output rectangle. This coordinate is included in the output. 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: To output the 100x100 area that is centered at the origin use:
@ -65,13 +53,13 @@ To output the 100x100 area that is centered at the origin use:
./stitch -divide 1 -xmin -50 -xmax 50 -ymin -50 -ymax 50 ./stitch -divide 1 -xmin -50 -xmax 50 -ymin -50 -ymax 50
``` ```
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: To remove images that would cause artifacts (You should recapture the deleted images afterwards):
``` Shell Session ``` Shell Session
./stitch -output capture.dzi ./stitch -cleanup 0.999
``` ```
To start the program interactively: To enter the parameters inside of the program:
``` Shell Session ``` Shell Session
./stitch ./stitch

View File

@ -1,164 +0,0 @@
// 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)
}
}
}

View File

@ -1,208 +0,0 @@
// 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
}

View File

@ -1,63 +0,0 @@
// 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.
}

View File

@ -1,220 +0,0 @@
// 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)
}

View File

@ -1,40 +0,0 @@
// 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
}

View File

@ -1,67 +0,0 @@
// 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
}

View File

@ -1,67 +0,0 @@
// 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
}

View File

@ -1,73 +0,0 @@
// 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
}

View File

@ -1,216 +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/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)
}

View File

@ -1,63 +0,0 @@
// 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()
}
}
}

113
bin/stitch/imagetile.go Normal file
View File

@ -0,0 +1,113 @@
// 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)
}

331
bin/stitch/imagetiles.go Normal file
View File

@ -0,0 +1,331 @@
// 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
}

View File

@ -1,356 +0,0 @@
// 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()
}

View File

@ -0,0 +1,88 @@
// 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
}

View File

@ -1,101 +0,0 @@
// 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.
}

View File

@ -1,21 +0,0 @@
// 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)
*/
}

284
bin/stitch/stitch.go Normal file
View File

@ -0,0 +1,284 @@
// 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)
}

View File

@ -1,156 +0,0 @@
// 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
}

View File

@ -1,166 +0,0 @@
// 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),
}
}

View File

@ -1,36 +0,0 @@
// 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)
}

View File

@ -1,4 +1,4 @@
// Copyright (c) 2019-2024 David Vogel // Copyright (c) 2019-2022 David Vogel
// //
// This software is released under the MIT License. // This software is released under the MIT License.
// https://opensource.org/licenses/MIT // https://opensource.org/licenses/MIT
@ -8,46 +8,20 @@ package main
import ( import (
"fmt" "fmt"
"image" "image"
"image/color"
"math"
"os" "os"
"sync" "sort"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
"github.com/google/hilbert"
) )
// 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 // 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) file, err := os.Open(imagePath)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("can't open file %v: %w", imagePath, err) return 0, 0, fmt.Errorf("can't open file %v: %w", imagePath, err)
@ -62,13 +36,41 @@ func GetImageFileDimension(imagePath string) (int, int, error) {
return image.Width, image.Height, nil return image.Width, image.Height, nil
} }
func GridifyRectangle(rect image.Rectangle, gridSize int) (result []image.Rectangle) { // getImageDifferenceValue returns the average quadratic difference of the (sub)pixels.
for y := DivideFloor(rect.Min.Y, gridSize); y <= DivideCeil(rect.Max.Y-1, gridSize); y++ { // 0 means the images are identical, +inf means that the images don't intersect.
for x := DivideFloor(rect.Min.X, gridSize); x <= DivideCeil(rect.Max.X-1, gridSize); x++ { 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++ {
tempRect := image.Rect(x*gridSize, y*gridSize, (x+1)*gridSize, (y+1)*gridSize) tempRect := image.Rect(x*gridSize, y*gridSize, (x+1)*gridSize, (y+1)*gridSize)
intersection := tempRect.Intersect(rect) if tempRect.Overlaps(rect) {
if !intersection.Empty() { result = append(result, tempRect)
result = append(result, intersection)
} }
} }
} }
@ -76,8 +78,63 @@ func GridifyRectangle(rect image.Rectangle, gridSize int) (result []image.Rectan
return 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. // 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 temp := a / b
if ((a ^ b) < 0) && (a%b != 0) { if ((a ^ b) < 0) && (a%b != 0) {
@ -88,7 +145,7 @@ func DivideFloor(a, b int) int {
} }
// Integer division that rounds to the next integer towards positive infinity. // 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 temp := a / b
if ((a ^ b) >= 0) && (a%b != 0) { if ((a ^ b) >= 0) && (a%b != 0) {
@ -98,43 +155,9 @@ func DivideCeil(a, b int) int {
return temp return temp
} }
// https://gist.github.com/cstockton/d611ced26bb6b4d3f7d4237abb8613c4 func maxInt(x, y int) int {
type LimitGroup struct { if x > y {
wg sync.WaitGroup return x
mu *sync.Mutex
c *sync.Cond
l, n int
} }
return y
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() }

View File

@ -1,4 +1,4 @@
-- Copyright (c) 2019-2024 David Vogel -- Copyright (c) 2019-2022 David Vogel
-- --
-- This software is released under the MIT License. -- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT -- https://opensource.org/licenses/MIT
@ -28,11 +28,10 @@ local Vec2 = require("noita-api.vec2")
Capture.MapCapturingCtx = Capture.MapCapturingCtx or ProcessRunner.New() Capture.MapCapturingCtx = Capture.MapCapturingCtx or ProcessRunner.New()
Capture.EntityCapturingCtx = Capture.EntityCapturingCtx 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. ---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. ---The rectangle is sized and placed in a way that aligns as pixel perfect as possible with the world coordinates.
---@param pos Vec2? -- Position of the viewport center in world coordinates. If set to nil, the viewport center will be queried automatically. ---@param pos Vec2|nil -- Position of the viewport center in world coordinates. If set to nil, the viewport center will be queried automatically.
---@return Vec2 topLeftCapture ---@return Vec2 topLeftCapture
---@return Vec2 bottomRightCapture ---@return Vec2 bottomRightCapture
---@return Vec2 topLeftWorld ---@return Vec2 topLeftWorld
@ -53,50 +52,39 @@ end
---This will block until all chunks in the virtual rectangle are loaded. ---This will block until all chunks in the virtual rectangle are loaded.
--- ---
---Don't set `ensureLoaded` to true when `pos` is nil! ---Don't set `ensureLoaded` to true when `pos` is nil!
---@param pos Vec2? -- Position of the viewport center in world coordinates. If set to nil, the viewport will not be modified. ---@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? -- If true, the function will wait until all chunks in the virtual rectangle are loaded. ---@param ensureLoaded boolean|nil -- 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 dontOverwrite boolean|nil -- 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 ctx ProcessRunnerCtx|nil -- The process runner context this runs in.
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio. ---@param outputPixelScale number|nil -- 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)
local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPixelScale, captureDelay) outputPixelScale = outputPixelScale or 0
if outputPixelScale == 0 or outputPixelScale == nil then
outputPixelScale = Coords:PixelScale()
end
local rectTopLeft, rectBottomRight = ScreenCapture.GetRect() local rectTopLeft, rectBottomRight = ScreenCapture.GetRect()
if Coords:InternalRectSize() ~= rectBottomRight - rectTopLeft then if Coords.WindowResolution ~= rectBottomRight - rectTopLeft then
error(string.format("internal rectangle size seems to have changed from %s to %s", Coords:InternalRectSize(), rectBottomRight - rectTopLeft)) error(string.format("window size seems to have changed from %s to %s", Coords.WindowResolution, rectBottomRight - rectTopLeft))
end end
local topLeftCapture, bottomRightCapture, topLeftWorld, bottomRightWorld = calculateCaptureRectangle(pos) local topLeftCapture, bottomRightCapture, topLeftWorld, bottomRightWorld = calculateCaptureRectangle(pos)
---Top left in output coordinates. ---Top left in output coordinates.
---@type Vec2 ---@type Vec2
local outputTopLeft = (topLeftWorld * outputPixelScale):Rounded() local outputTopLeft
if outputPixelScale > 0 then
outputTopLeft = (topLeftWorld * outputPixelScale):Rounded()
else
outputTopLeft = topLeftWorld
end
-- Check if the file exists, and if we are allowed to overwrite it. -- 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 if dontOverwrite and Utils.FileExists(string.format("mods/noita-mapcap/output/%d,%d.png", outputTopLeft.x, outputTopLeft.y)) then
return return
end 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 pos then CameraAPI.SetPos(pos) end
if ensureLoaded then if ensureLoaded then
local delayFrames = 0 local delayFrames = 0
if ctx then ctx.state.WaitFrames = delayFrames end
repeat repeat
-- Prematurely stop capturing if that is requested by the context. -- Prematurely stop capturing if that is requested by the context.
if ctx and ctx:IsStopping() then return end if ctx and ctx:IsStopping() then return end
@ -106,43 +94,25 @@ local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPi
if pos then CameraAPI.SetPos(pos + Vec2(math.random(-10, 10), math.random(-10, 10))) end if pos then CameraAPI.SetPos(pos + Vec2(math.random(-10, 10), math.random(-10, 10))) end
wait(0) wait(0)
delayFrames = delayFrames + 1 delayFrames = delayFrames + 1
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 1 end if ctx then ctx.state.WaitFrames = delayFrames end
if pos then CameraAPI.SetPos(pos) end if pos then CameraAPI.SetPos(pos) end
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) wait(0)
delayFrames = delayFrames + 1 delayFrames = delayFrames + 1
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 1 end if ctx then ctx.state.WaitFrames = delayFrames end
local topLeftBounds, bottomRightBounds = CameraAPI:Bounds() local topLeftBounds, bottomRightBounds = CameraAPI:Bounds()
until DoesWorldExistAt(topLeftBounds.x, topLeftBounds.y, bottomRightBounds.x, bottomRightBounds.y) until DoesWorldExistAt(topLeftBounds.x, topLeftBounds.y, bottomRightBounds.x, bottomRightBounds.y)
-- Chunks are loaded and will be drawn on the *next* frame. -- Chunks are loaded and will be drawn on the *next* frame.
end end
if ctx then ctx.state.WaitFrames = 0 end
-- Suspend UI drawing for 1 frame. -- Suspend UI drawing for 1 frame.
UI:SuspendDrawing(1) UI:SuspendDrawing(1)
-- First we wait one frame for the current state to be drawn.
wait(0) wait(0)
-- At this point the needed frame is fully drawn, but the framebuffers are swapped. -- Fetch coordinates again, as they may have changed.
-- 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 if not pos then
topLeftCapture, bottomRightCapture, topLeftWorld, bottomRightWorld = calculateCaptureRectangle(pos) topLeftCapture, bottomRightCapture, topLeftWorld, bottomRightWorld = calculateCaptureRectangle(pos)
if outputPixelScale > 0 then if outputPixelScale > 0 then
@ -152,10 +122,6 @@ local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPi
end end
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. -- The top left world position needs to be upscaled by the pixel scale.
-- Otherwise it's not possible to stitch the images correctly. -- Otherwise it's not possible to stitch the images correctly.
if not ScreenCapture.Capture(topLeftCapture, bottomRightCapture, outputTopLeft, (bottomRightWorld - topLeftWorld) * outputPixelScale) then if not ScreenCapture.Capture(topLeftCapture, bottomRightCapture, outputTopLeft, (bottomRightWorld - topLeftWorld) * outputPixelScale) then
@ -166,37 +132,6 @@ local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPi
MonitorStandby.ResetTimer() MonitorStandby.ResetTimer()
end 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. ---Map capture process runner context error handler callback. Just rolls off the tongue.
---@param err string ---@param err string
---@param scope "init"|"do"|"end" ---@param scope "init"|"do"|"end"
@ -209,9 +144,8 @@ end
---Use `Capture.MapCapturingCtx` to stop, control or view the progress. ---Use `Capture.MapCapturingCtx` to stop, control or view the progress.
---@param origin Vec2 -- Center of the spiral in world pixels. ---@param origin Vec2 -- Center of the spiral in world pixels.
---@param captureGridSize number -- The grid size in world pixels. ---@param captureGridSize number -- The grid size in world pixels.
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio. ---@param outputPixelScale number|nil -- 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)
function Capture:StartCapturingSpiral(origin, captureGridSize, outputPixelScale, captureDelay)
-- Create file that signals that there are files in the output directory. -- Create file that signals that there are files in the output directory.
local file = io.open("mods/noita-mapcap/output/nonempty", "a") local file = io.open("mods/noita-mapcap/output/nonempty", "a")
@ -235,23 +169,23 @@ function Capture:StartCapturingSpiral(origin, captureGridSize, outputPixelScale,
repeat repeat
-- +x -- +x
for _ = 1, i, 1 do for _ = 1, i, 1 do
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay) captureScreenshot(pos, true, true, ctx, outputPixelScale)
pos:Add(Vec2(captureGridSize, 0)) pos:Add(Vec2(captureGridSize, 0))
end end
-- +y -- +y
for _ = 1, i, 1 do for _ = 1, i, 1 do
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay) captureScreenshot(pos, true, true, ctx, outputPixelScale)
pos:Add(Vec2(0, captureGridSize)) pos:Add(Vec2(0, captureGridSize))
end end
i = i + 1 i = i + 1
-- -x -- -x
for _ = 1, i, 1 do for _ = 1, i, 1 do
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay) captureScreenshot(pos, true, true, ctx, outputPixelScale)
pos:Add(Vec2(-captureGridSize, 0)) pos:Add(Vec2(-captureGridSize, 0))
end end
-- -y -- -y
for _ = 1, i, 1 do for _ = 1, i, 1 do
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay) captureScreenshot(pos, true, true, ctx, outputPixelScale)
pos:Add(Vec2(0, -captureGridSize)) pos:Add(Vec2(0, -captureGridSize))
end end
i = i + 1 i = i + 1
@ -268,32 +202,27 @@ function Capture:StartCapturingSpiral(origin, captureGridSize, outputPixelScale,
self.MapCapturingCtx:Run(nil, handleDo, handleEnd, mapCapturingCtxErrHandler) self.MapCapturingCtx:Run(nil, handleDo, handleEnd, mapCapturingCtxErrHandler)
end end
---Starts the capturing process of the given area using a hilbert curve. ---Starts the capturing process of the given area.
---Use `Capture.MapCapturingCtx` to stop, control or view the process. ---Use `Capture.MapCapturingCtx` to stop, control or view the process.
---@param topLeft Vec2 -- Top left of the to be captured rectangle. ---@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 bottomRight Vec2 -- Non included bottom left of the to be captured rectangle.
---@param captureGridSize number -- The grid size in world pixels. ---@param captureGridSize number -- The grid size in world pixels.
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio. ---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
---@param captureDelay number? -- The number of additional frames to wait before a screen capture. function Capture:StartCapturingArea(topLeft, bottomRight, captureGridSize, outputPixelScale)
function Capture:StartCapturingAreaHilbert(topLeft, bottomRight, captureGridSize, outputPixelScale, captureDelay)
-- Create file that signals that there are files in the output directory. -- Create file that signals that there are files in the output directory.
local file = io.open("mods/noita-mapcap/output/nonempty", "a") local file = io.open("mods/noita-mapcap/output/nonempty", "a")
if file ~= nil then file:close() end if file ~= nil then file:close() end
-- The capture offset which is needed to center the grid cells in the viewport. ---The rectangle in grid coordinates.
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 ---@type Vec2, Vec2
local gridTopLeft, gridBottomRight = (validTopLeftWorld / captureGridSize):Rounded("floor"), ((validBottomRightWorld) / captureGridSize):Rounded("ceil") - Vec2(1, 1) local gridTopLeft, gridBottomRight = (topLeft / captureGridSize):Rounded("floor"), (bottomRight / captureGridSize):Rounded("floor")
---Size of the rectangle in grid cells. -- 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.
---@type Vec2 ---@type Vec2
local gridSize = gridBottomRight - gridTopLeft local gridSize = gridBottomRight - gridTopLeft
@ -322,8 +251,8 @@ function Capture:StartCapturingAreaHilbert(topLeft, bottomRight, captureGridSize
---Position in world coordinates. ---Position in world coordinates.
---@type Vec2 ---@type Vec2
local pos = (hilbertPos + gridTopLeft) * captureGridSize local pos = (hilbertPos + gridTopLeft) * captureGridSize
pos:Add(captureOffset) -- Move to center of grid cell. pos:Add(Vec2(captureGridSize/2, captureGridSize/2)) -- Move to center of grid cell.
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay) captureScreenshot(pos, true, true, ctx, outputPixelScale)
ctx.state.Current = ctx.state.Current + 1 ctx.state.Current = ctx.state.Current + 1
end end
@ -341,73 +270,9 @@ function Capture:StartCapturingAreaHilbert(topLeft, bottomRight, captureGridSize
self.MapCapturingCtx:Run(nil, handleDo, handleEnd, mapCapturingCtxErrHandler) self.MapCapturingCtx:Run(nil, handleDo, handleEnd, mapCapturingCtxErrHandler)
end 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. ---Starts the live capturing process.
---Use `Capture.MapCapturingCtx` to stop, control or view the process. ---Use `Capture.MapCapturingCtx` to stop, control or view the process.
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio. ---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
function Capture:StartCapturingLive(outputPixelScale) function Capture:StartCapturingLive(outputPixelScale)
---Queries the mod settings for the live capture parameters. ---Queries the mod settings for the live capture parameters.
@ -445,7 +310,7 @@ function Capture:StartCapturingLive(outputPixelScale)
if oldPos then distanceSqr = CameraAPI.GetPos():DistanceSqr(oldPos) else distanceSqr = math.huge end 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) until ctx:IsStopping() or ((delayFrames >= interval or distanceSqr >= maxDistanceSqr) and distanceSqr >= minDistanceSqr)
captureScreenshot(nil, false, false, ctx, outputPixelScale, nil) captureScreenshot(nil, false, false, ctx, outputPixelScale)
oldPos = CameraAPI.GetPos() oldPos = CameraAPI.GetPos()
until ctx:IsStopping() until ctx:IsStopping()
end end
@ -460,8 +325,8 @@ function Capture:StartCapturingLive(outputPixelScale)
self.MapCapturingCtx:Run(nil, handleDo, handleEnd, mapCapturingCtxErrHandler) self.MapCapturingCtx:Run(nil, handleDo, handleEnd, mapCapturingCtxErrHandler)
end end
---Gathers all entities on the screen (around x, y within radius), serializes them, appends them into entityFile and/or modifies those entities. ---Gathers all entities on the screen (around x, y within radius), serializes them, appends them into entityFile and modifies those entities.
---@param file file*? ---@param file file*|nil
---@param modify boolean ---@param modify boolean
---@param x number ---@param x number
---@param y number ---@param y number
@ -487,7 +352,7 @@ local function captureModifyEntities(file, modify, x, y, radius)
file:write(",\n\t", JSON.Marshal(rootEntity), "\n", "]") file:write(",\n\t", JSON.Marshal(rootEntity), "\n", "]")
end end
-- Disabling this component will prevent entities from being killed/reset when they go offscreen. -- Disabling this component will prevent entites 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. -- 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) -- 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") local components = rootEntity:GetComponents("CameraBoundComponent")
@ -569,11 +434,6 @@ local function captureModifyEntities(file, modify, x, y, radius)
-- Prevent it from being modified again. -- Prevent it from being modified again.
rootEntity:AddTag("MapModified") 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
end end
@ -584,13 +444,13 @@ local function captureModifyEntities(file, modify, x, y, radius)
end end
--- ---
---@return file*? ---@return file*|nil
local function createOrOpenEntityCaptureFile() local function createOrOpenEntityCaptureFile()
-- Make sure the file exists. -- Make sure the file exists.
local file = io.open("mods/noita-mapcap/output/entities.json", "a") local file = io.open("mods/noita-mapcap/output/entities.json", "a")
if file ~= nil then file:close() end if file ~= nil then file:close() end
-- Create or reopen entities JSON file. -- Create or reopen entities CSV 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. 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 if file == nil then return nil end
@ -643,194 +503,6 @@ function Capture:StartCapturingEntities(store, modify)
self.EntityCapturingCtx:Run(handleInit, handleDo, handleEnd, handleErr) self.EntityCapturingCtx:Run(handleInit, handleDo, handleEnd, handleErr)
end 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. ---Starts the capturing process based on user/mod settings.
function Capture:StartCapturing() function Capture:StartCapturing()
Message:CatchException("Capture:StartCapturing", function() Message:CatchException("Capture:StartCapturing", function()
@ -838,26 +510,20 @@ function Capture:StartCapturing()
local mode = ModSettingGet("noita-mapcap.capture-mode") local mode = ModSettingGet("noita-mapcap.capture-mode")
local outputPixelScale = ModSettingGet("noita-mapcap.pixel-scale") local outputPixelScale = ModSettingGet("noita-mapcap.pixel-scale")
local captureGridSize = tonumber(ModSettingGet("noita-mapcap.grid-size")) local captureGridSize = tonumber(ModSettingGet("noita-mapcap.grid-size"))
local captureDelay = tonumber(ModSettingGet("noita-mapcap.capture-delay"))
if mode == "live" then if mode == "live" then
self:StartCapturingLive(outputPixelScale) 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 elseif mode == "area" then
local area = ModSettingGet("noita-mapcap.area") local area = ModSettingGet("noita-mapcap.area")
if area == "custom" then if area == "custom" then
local topLeft = Vec2(ModSettingGet("noita-mapcap.area-top-left")) local topLeft = Vec2(ModSettingGet("noita-mapcap.area-top-left"))
local bottomRight = Vec2(ModSettingGet("noita-mapcap.area-bottom-right")) local bottomRight = Vec2(ModSettingGet("noita-mapcap.area-bottom-right"))
self:StartCapturingAreaScan(topLeft, bottomRight, captureGridSize, outputPixelScale, captureDelay) self:StartCapturingArea(topLeft, bottomRight, captureGridSize, outputPixelScale)
else else
---@type fun():Vec2, Vec2 local predefinedArea = Config.CaptureArea[area]
local predefinedAreaFunction = Config.CaptureArea[area] if predefinedArea then
if predefinedAreaFunction then self:StartCapturingArea(predefinedArea.TopLeft, predefinedArea.BottomRight, captureGridSize, outputPixelScale)
local topLeft, bottomRight = predefinedAreaFunction()
self:StartCapturingAreaScan(topLeft, bottomRight, captureGridSize, outputPixelScale, captureDelay)
else else
Message:ShowRuntimeError("PredefinedArea", string.format("Unknown predefined capturing area %q", tostring(area))) Message:ShowRuntimeError("PredefinedArea", string.format("Unknown predefined capturing area %q", tostring(area)))
end end
@ -866,13 +532,13 @@ function Capture:StartCapturing()
local origin = ModSettingGet("noita-mapcap.capture-mode-spiral-origin") local origin = ModSettingGet("noita-mapcap.capture-mode-spiral-origin")
if origin == "custom" then if origin == "custom" then
local originVec = Vec2(ModSettingGet("noita-mapcap.capture-mode-spiral-origin-vector")) local originVec = Vec2(ModSettingGet("noita-mapcap.capture-mode-spiral-origin-vector"))
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale, captureDelay) self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale)
elseif origin == "0" then elseif origin == "0" then
local originVec = Vec2(0, 0) local originVec = Vec2(0, 0)
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale, captureDelay) self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale)
elseif origin == "current" then elseif origin == "current" then
local originVec = CameraAPI:GetPos() local originVec = CameraAPI:GetPos()
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale, captureDelay) self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale)
else else
Message:ShowRuntimeError("SpiralOrigin", string.format("Unknown spiral origin %q", tostring(origin))) Message:ShowRuntimeError("SpiralOrigin", string.format("Unknown spiral origin %q", tostring(origin)))
end end
@ -892,5 +558,4 @@ end
function Capture:StopCapturing() function Capture:StopCapturing()
self.EntityCapturingCtx:Stop() self.EntityCapturingCtx:Stop()
self.MapCapturingCtx:Stop() self.MapCapturingCtx:Stop()
self.PlayerPathCapturingCtx:Stop()
end end

View File

@ -1,4 +1,4 @@
-- Copyright (c) 2019-2024 David Vogel -- Copyright (c) 2019-2022 David Vogel
-- --
-- This software is released under the MIT License. -- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT -- https://opensource.org/licenses/MIT
@ -47,14 +47,14 @@ function Check:Regular(interval)
self.Counter = interval self.Counter = interval
-- Remove some messages, so they will automatically disappear when the problem is solved. -- Remove some messages, so they will automatically disappear when the problem is solved.
Message:CloseAutoClose() Message:CloseAutoclose()
-- Compare Noita config and actual window resolution. -- Compare Noita config and actual window resolution.
local topLeft, bottomRight = ScreenCap.GetRect() -- Actual window client area. local topLeft, bottomRight = ScreenCap.GetRect() -- Actual window client area.
if topLeft and bottomRight then if topLeft and bottomRight then
local actual = bottomRight - topLeft local actual = bottomRight - topLeft
if actual ~= Coords:InternalRectSize() then if actual ~= Coords.WindowResolution then
Message:ShowWrongResolution(Modification.AutoSet, string.format("Internal rectangle size is %s. Current resolution is %s.", Coords:InternalRectSize(), actual)) Message:ShowWrongResolution(Modification.AutoSet, string.format("Old window resolution is %s. Current resolution is %s.", Coords.WindowResolution, actual))
end end
else else
Message:ShowRuntimeError("GetRect", "Couldn't determine window resolution.") Message:ShowRuntimeError("GetRect", "Couldn't determine window resolution.")
@ -86,12 +86,6 @@ function Check:Regular(interval)
Message:ShowSetNoitaSettings(Modification.AutoSet, string.format("Screenshake intensity is %s, expected %s.", self.StartupConfig.screenshake_intensity, expected)) Message:ShowSetNoitaSettings(Modification.AutoSet, string.format("Screenshake intensity is %s, expected %s.", self.StartupConfig.screenshake_intensity, expected))
end end
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
-- Magic numbers stuff doesn't need a forced restart, just a normal restart by the user. -- Magic numbers stuff doesn't need a forced restart, just a normal restart by the user.
if magic["VIRTUAL_RESOLUTION_X"] and magic["VIRTUAL_RESOLUTION_Y"] then if magic["VIRTUAL_RESOLUTION_X"] and magic["VIRTUAL_RESOLUTION_Y"] then
@ -120,7 +114,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. -- 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 mode = ModSettingGet("noita-mapcap.capture-mode")
local captureGridSize = tonumber(ModSettingGet("noita-mapcap.grid-size")) local captureGridSize = tonumber(ModSettingGet("noita-mapcap.grid-size"))
if (mode ~= "live" and mode ~= "animation") and (Coords.VirtualResolution.x < captureGridSize or Coords.VirtualResolution.y < captureGridSize) then if mode ~= "live" and (Coords.VirtualResolution.x < captureGridSize or Coords.VirtualResolution.y < captureGridSize) then
Message:ShowGeneralSettingsProblem( Message:ShowGeneralSettingsProblem(
"The virtual resolution is smaller than the capture grid size.", "The virtual resolution is smaller than the capture grid size.",
"This means that you will get black areas in your final stitched image.", "This means that you will get black areas in your final stitched image.",

View File

@ -1,9 +1,8 @@
-- Copyright (c) 2022-2024 David Vogel -- Copyright (c) 2022 David Vogel
-- --
-- This software is released under the MIT License. -- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT -- https://opensource.org/licenses/MIT
local NXML = require("luanxml.nxml")
local Vec2 = require("noita-api.vec2") local Vec2 = require("noita-api.vec2")
-- List of components that will be disabled on every encountered entity. -- List of components that will be disabled on every encountered entity.
@ -28,65 +27,22 @@ Config.ComponentsToDisable = {
--"AudioComponent", --"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 = { Config.CaptureArea = {
-- Base layout: Every part outside this is based on a similar layout, but uses different materials/seeds. -- Base layout: Every part outside this is based on a similar layout, but uses different materials/seeds.
["1x1"] = getBaseArea, ["1x1"] = {
TopLeft = Vec2(-17920, -7168), -- in world coordinates.
BottomRight = Vec2(17920, 17408), -- in world coordinates. This pixel is not included in the rectangle.
},
-- Main world: The main world with 3 parts: sky, normal and hell. -- Main world: The main world with 3 parts: sky, normal and hell.
["1x3"] = function() ["1x3"] = {
local width, height = BiomeMapGetSize() TopLeft = Vec2(-17920, -31744), -- in world coordinates.
local topLeft, bottomRight = getBaseArea() BottomRight = Vec2(17920, 41984), -- in world coordinates. This pixel is not included in the rectangle.
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. -- Extended: Main world + a fraction of the parallel worlds to the left and right.
["1.5x3"] = function() ["1.5x3"] = {
local width, height = BiomeMapGetSize() TopLeft = Vec2(-25600, -31744), -- in world coordinates.
local topLeft, bottomRight = getBaseArea() BottomRight = Vec2(25600, 41984), -- in world coordinates. This pixel is not included in the rectangle.
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,
} }

View File

@ -126,7 +126,7 @@ function Coords:PixelScale()
end end
---Converts the given virtual/world coordinates into window/screen coordinates. ---Converts the given virtual/world coordinates into window/screen coordinates.
---@param world Vec2 -- World coordinate, origin is near the cave entrance. ---@param world Vec2 -- World coordiante, origin is near the cave entrance.
---@param viewportCenter Vec2|nil -- Result of `GameGetCameraPos()`. Will be queried automatically if set to nil. ---@param viewportCenter Vec2|nil -- Result of `GameGetCameraPos()`. Will be queried automatically if set to nil.
---@return Vec2 window ---@return Vec2 window
function Coords:ToWindow(world, viewportCenter) function Coords:ToWindow(world, viewportCenter)

View File

@ -1,58 +0,0 @@
-- 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

View File

@ -18,7 +18,7 @@ local CameraAPI = {}
--- ---
---@param strength number ---@param strength number
---@param position Vec2|nil -- Defaults to camera position if not set. ---@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 if position == nil then
return GameScreenshake(strength) return GameScreenshake(strength)
end end

View File

@ -28,7 +28,7 @@ end
-- Package doesn't exist when the Lua API is restricted. -- Package doesn't exist when the Lua API is restricted.
-- Therefore we create it here and apply some default values. -- Therefore we create it here and apply some default values.
package = package or {} 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.preload = package.preload or {}
package.loaded = package.loaded or { package.loaded = package.loaded or {
_G = _G, _G = _G,
@ -141,13 +141,12 @@ end
---Set up some stuff so `require` works as expected. ---Set up some stuff so `require` works as expected.
---@param libPath any -- Path to the libraries directory of this mod. ---@param libPath any -- Path to the libraries directory of this mod.
local function setup(libPath) local function setup(libPath)
-- Add the library directory of the mod as base for any `require` lookups. -- Add the files folder of the given 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 .. "?.lua" package.path = package.path .. "./" .. libPath .. "?/init.lua;"
package.path = package.path .. ";./" .. libPath .. "?/init.lua"
-- Add the library directory of Noita itself. -- 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 end
return setup return setup

View File

@ -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 return ComponentSetValue2(self.ID, fieldName, ...) -- TODO: Rework Noita API to handle vectors, and use a vector instead of shitty multi value arguments
end end
---Returns one or many values matching the type or subtypes of the requested field in a component sub-object. ---Returns one or many values matching the type or subtypes of the requested field in a component subobject.
---Reports error and returns nil if the field type is not supported or 'object_name' is not a metaobject. ---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. ---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 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 end
---Sets the value of a field in a component sub-object. Value(s) should have a type matching the field type. ---Sets the value of a field in a component subobject. 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. ---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 objectName string
---@param fieldName string ---@param fieldName string
@ -134,16 +134,6 @@ function NoitaComponent:GetTypeName()
return ComponentGetTypeName(self.ID) return ComponentGetTypeName(self.ID)
end 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 -- -- JSON Implementation --
------------------------- -------------------------

View File

@ -48,7 +48,7 @@ end
---Enables the trailer mode and some other things: ---Enables the trailer mode and some other things:
--- ---
--- - Disables in-game GUI. --- - Disables ingame GUI.
--- - Opens fog of war everywhere (Not the same as disabling it completely). --- - Opens fog of war everywhere (Not the same as disabling it completely).
--- - Enables `mTrailerMode`, whatever that does. --- - Enables `mTrailerMode`, whatever that does.
--- ---

View File

@ -35,7 +35,7 @@ end
---@param posX number -- X coordinate in world (virtual) pixels. ---@param posX number -- X coordinate in world (virtual) pixels.
---@param posY number -- Y coordinate in world (virtual) pixels. ---@param posY number -- Y coordinate in world (virtual) pixels.
---@return NoitaEntity|nil ---@return NoitaEntity|nil
function EntityAPI.Load(filename, posX, posY) -- TODO: Change to use Vec2 object function EntityAPI.Load(filename, posX, posY)
return EntityAPI.Wrap(EntityLoad(filename, posX, posY)) return EntityAPI.Wrap(EntityLoad(filename, posX, posY))
end end
@ -44,7 +44,7 @@ end
---@param posX number -- X coordinate in world (virtual) pixels. ---@param posX number -- X coordinate in world (virtual) pixels.
---@param posY number -- Y coordinate in world (virtual) pixels. ---@param posY number -- Y coordinate in world (virtual) pixels.
---@return NoitaEntity|nil ---@return NoitaEntity|nil
function EntityAPI.LoadEndGameItem(filename, posX, posY) -- TODO: Change to use Vec2 object function EntityAPI.LoadEndGameItem(filename, posX, posY)
return EntityAPI.Wrap(EntityLoadEndGameItem(filename, posX, posY)) return EntityAPI.Wrap(EntityLoadEndGameItem(filename, posX, posY))
end end
@ -52,7 +52,7 @@ end
---@param filename string ---@param filename string
---@param posX number -- X coordinate in world (virtual) pixels. ---@param posX number -- X coordinate in world (virtual) pixels.
---@param posY number -- Y coordinate in world (virtual) pixels. ---@param posY number -- Y coordinate in world (virtual) pixels.
function EntityAPI.LoadCameraBound(filename, posX, posY) -- TODO: Change to use Vec2 object function EntityAPI.LoadCameraBound(filename, posX, posY)
return EntityLoadCameraBound(filename, posX, posY) return EntityLoadCameraBound(filename, posX, posY)
end end
@ -152,7 +152,7 @@ end
---@param rotation number ---@param rotation number
---@param scaleX number ---@param scaleX number
---@param scaleY number ---@param scaleY number
function NoitaEntity:SetTransform(x, y, rotation, scaleX, scaleY) -- TODO: Change to use Vec2 object function NoitaEntity:SetTransform(x, y, rotation, scaleX, scaleY)
return EntitySetTransform(self.ID, x, y, rotation, scaleX, scaleY) return EntitySetTransform(self.ID, x, y, rotation, scaleX, scaleY)
end end
@ -162,13 +162,13 @@ end
---@param rotation number ---@param rotation number
---@param scaleX number ---@param scaleX number
---@param scaleY number ---@param scaleY number
function NoitaEntity:SetAndApplyTransform(x, y, rotation, scaleX, scaleY) -- TODO: Change to use Vec2 object function NoitaEntity:SetAndApplyTransform(x, y, rotation, scaleX, scaleY)
return EntityApplyTransform(self.ID, x, y, rotation, scaleX, scaleY) return EntityApplyTransform(self.ID, x, y, rotation, scaleX, scaleY)
end end
---Returns the transformation of the entity. ---Returns the transformation of the entity.
---@return number x, number y, number rotation, number scaleX, number scaleY ---@return number x, number y, number rotation, number scaleX, number scaleY
function NoitaEntity:GetTransform() -- TODO: Change to use Vec2 object function NoitaEntity:GetTransform()
return EntityGetTransform(self.ID) return EntityGetTransform(self.ID)
end end
@ -261,7 +261,7 @@ end
---@param posY number -- X coordinate in world (virtual) pixels. ---@param posY number -- X coordinate in world (virtual) pixels.
---@param radius number -- Radius in world (virtual) pixels. ---@param radius number -- Radius in world (virtual) pixels.
---@return NoitaEntity[] ---@return NoitaEntity[]
function EntityAPI.GetInRadius(posX, posY, radius) -- TODO: Change to use Vec2 object function EntityAPI.GetInRadius(posX, posY, radius)
local entityIDs = EntityGetInRadius(posX, posY, radius) or {} local entityIDs = EntityGetInRadius(posX, posY, radius) or {}
local result = {} local result = {}
for _, entityID in ipairs(entityIDs) do for _, entityID in ipairs(entityIDs) do
@ -276,7 +276,7 @@ end
---@param radius number -- Radius in world (virtual) pixels. ---@param radius number -- Radius in world (virtual) pixels.
---@param tag string ---@param tag string
---@return NoitaEntity[] ---@return NoitaEntity[]
function EntityAPI.GetInRadiusWithTag(posX, posY, radius, tag) -- TODO: Change to use Vec2 object function EntityAPI.GetInRadiusWithTag(posX, posY, radius, tag)
local entityIDs = EntityGetInRadiusWithTag(posX, posY, radius, tag) or {} local entityIDs = EntityGetInRadiusWithTag(posX, posY, radius, tag) or {}
local result = {} local result = {}
for _, entityID in ipairs(entityIDs) do for _, entityID in ipairs(entityIDs) do
@ -289,7 +289,7 @@ end
---@param posX number -- X coordinate in world (virtual) pixels. ---@param posX number -- X coordinate in world (virtual) pixels.
---@param posY number -- X coordinate in world (virtual) pixels. ---@param posY number -- X coordinate in world (virtual) pixels.
---@return NoitaEntity|nil ---@return NoitaEntity|nil
function EntityAPI.GetClosest(posX, posY) -- TODO: Change to use Vec2 object function EntityAPI.GetClosest(posX, posY)
return EntityAPI.Wrap(EntityGetClosest(posX, posY)) return EntityAPI.Wrap(EntityGetClosest(posX, posY))
end end
@ -340,72 +340,6 @@ end
-- TODO: Add missing Noita API methods and functions. -- 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 ---@return boolean
function NoitaEntity:IsPlayer() function NoitaEntity:IsPlayer()

View File

@ -53,15 +53,7 @@ end
---@param val number ---@param val number
---@return string ---@return string
function lib.MarshalNumber(val) function lib.MarshalNumber(val)
-- Edge cases, as there is no real solution to this. -- TODO: Marshal NaN, +Inf, -Inf, ... correctly
-- 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) return tostring(val)
end end

View File

@ -3,7 +3,7 @@
-- This software is released under the MIT License. -- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT -- https://opensource.org/licenses/MIT
-- This contains just some utilities that may be useful to have. -- This contains just some utilitites that may be useful to have.
local DebugAPI = require("noita-api.debug") local DebugAPI = require("noita-api.debug")

View File

@ -1,4 +1,4 @@
-- Copyright (c) 2019-2023 David Vogel -- Copyright (c) 2019-2022 David Vogel
-- --
-- This software is released under the MIT License. -- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT -- https://opensource.org/licenses/MIT
@ -24,13 +24,6 @@ ffi.cdef([[
LONG bottom; LONG bottom;
} RECT; } RECT;
typedef struct {
LONG x;
LONG y;
LONG width;
LONG height;
} GLViewportDims;
bool GetRect(RECT* rect); bool GetRect(RECT* rect);
bool Capture(RECT* rect, int x, int y, int sx, int sy); bool Capture(RECT* rect, int x, int y, int sx, int sy);
]]) ]])

View File

@ -1,3 +1,3 @@
<MagicNumbers <MagicNumbers
DEBUG_FREE_CAMERA_SPEED="10" DEBUG_FREE_CAMERA_SPEED="1"
></MagicNumbers> ></MagicNumbers>

View File

@ -1,4 +1,4 @@
-- Copyright (c) 2019-2024 David Vogel -- Copyright (c) 2019-2022 David Vogel
-- --
-- This software is released under the MIT License. -- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT -- https://opensource.org/licenses/MIT
@ -12,19 +12,18 @@
-------------------------- --------------------------
local Coords = require("coordinates") local Coords = require("coordinates")
local DebugAPI = require("noita-api.debug")
---------- ----------
-- Code -- -- Code --
---------- ----------
---Removes all messages with the AutoClose flag. ---Removes all messages with the autoclose flag.
---Use this before you recreate all auto closing messages. ---Use this before you recreate all auto closing messages.
function Message:CloseAutoClose() function Message:CloseAutoclose()
self.List = self.List or {} self.List = self.List or {}
for k, message in pairs(self.List) do for k, message in pairs(self.List) do
if message.AutoClose then if message.Autoclose then
self.List[k] = nil self.List[k] = nil
end end
end end
@ -65,7 +64,7 @@ function Message:ShowResetNoitaSettings()
Lines = { Lines = {
"You requested to reset some game settings like:", "You requested to reset some game settings like:",
"- Custom resolutions", "- Custom resolutions",
"- Screen-shake intensity", "- Screenshake intensity",
" ", " ",
"Press the following button to reset the settings and close Noita automatically:", "Press the following button to reset the settings and close Noita automatically:",
}, },
@ -95,7 +94,7 @@ function Message:ShowSetNoitaSettings(callback, desc)
Actions = { Actions = {
{ Name = "Setup and close (May corrupt current save!)", Hint = nil, HintDesc = nil, Callback = callback }, { 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 end
@ -112,7 +111,7 @@ function Message:ShowRequestRestart(desc)
" ", " ",
"To resolve this issue, restart the game.", "To resolve this issue, restart the game.",
}, },
AutoClose = true, -- This message will automatically close. Autoclose = true, -- This message will automatically close.
} }
end end
@ -128,15 +127,12 @@ function Message:ShowWrongResolution(callback, desc)
"The resolution changed:", "The resolution changed:",
desc or "", desc or "",
" ", " ",
"Press the button at the bottom to set up and close Noita automatically.", "To fix: Restart Noita or revert the change."
" ",
"You can always reset any custom settings by right clicking the `start capture`",
"button at the top left.",
}, },
Actions = { Actions = {
{ Name = "Setup and close (May corrupt current save!)", Hint = nil, HintDesc = nil, Callback = callback }, { Name = "Query settings again", Hint = nil, HintDesc = nil, Callback = function() Coords:ReadResolutions() end },
}, },
AutoClose = true, -- This message will automatically close. Autoclose = true, -- This message will automatically close.
} }
end end
@ -166,7 +162,7 @@ function Message:ShowGeneralSettingsProblem(...)
self.List["GeneralSettingsProblem"] = { self.List["GeneralSettingsProblem"] = {
Type = "hint", Type = "hint",
Lines = { ... }, Lines = { ... },
AutoClose = true, -- This message will automatically close. Autoclose = true, -- This message will automatically close.
} }
end end
@ -180,61 +176,3 @@ function Message:ShowGeneralInstallationProblem(...)
Lines = { ... }, Lines = { ... },
} }
end 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

View File

@ -1,4 +1,4 @@
-- Copyright (c) 2022-2024 David Vogel -- Copyright (c) 2022 David Vogel
-- --
-- This software is released under the MIT License. -- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT -- https://opensource.org/licenses/MIT
@ -19,11 +19,9 @@
local CameraAPI = require("noita-api.camera") local CameraAPI = require("noita-api.camera")
local Coords = require("coordinates") local Coords = require("coordinates")
local ffi = require("ffi") local ffi = require("ffi")
local Memory = require("memory")
local NXML = require("luanxml.nxml") local NXML = require("luanxml.nxml")
local Utils = require("noita-api.utils") local Utils = require("noita-api.utils")
local Vec2 = require("noita-api.vec2") local Vec2 = require("noita-api.vec2")
local DebugAPI = require("noita-api.debug")
---------- ----------
-- Code -- -- Code --
@ -94,316 +92,29 @@ end
---@param memory table ---@param memory table
function Modification.SetMemoryOptions(memory) function Modification.SetMemoryOptions(memory)
-- Lookup table with the following hierarchy: -- Lookup table with the following hierarchy:
-- DevBuild -> OS -> BuildDate -> Option -> ModFunc. -- DevBuild -> OS -> BuildDate -> Option -> Address.
local lookup = { local lookup = {
[true] = { [true] = {
Windows = { Windows = {
{_Offset = 0x00F77B0C, _BuildString = "Build Apr 23 2021 18:36:55", -- GOG dev build. [0x00F77B0C] = { _BuildString = "Build Apr 23 2021 18:36:55", -- GOG 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. mPostFxDisabled = 0x010E3B6C,
mGuiDisabled = function(value) ffi.cast("char*", 0x010E3B6D)[0] = value end, mGuiDisabled = 0x010E3B6D,
mGuiHalfSize = function(value) ffi.cast("char*", 0x010E3B6E)[0] = value end, mGuiHalfSize = 0x010E3B6E,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010E3B6F)[0] = value end, mFogOfWarOpenEverywhere = 0x010E3B6F,
mTrailerMode = function(value) ffi.cast("char*", 0x010E3B70)[0] = value end, mTrailerMode = 0x010E3B70,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010E3B71)[0] = value end, mDayTimeRotationPause = 0x010E3B71,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010E3B72)[0] = value end, mPlayerNeverDies = 0x010E3B72,
mFreezeAI = function(value) ffi.cast("char*", 0x010E3B73)[0] = value end, mFreezeAI = 0x010E3B73,
}, },
{_Offset = 0x00F80384, _BuildString = "Build Apr 23 2021 18:40:40", -- Steam dev build. [0x00F80384] = { _BuildString = "Build Apr 23 2021 18:40:40", -- Steam build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x010EDEBC)[0] = value end, mPostFxDisabled = 0x010EDEBC,
mGuiDisabled = function(value) ffi.cast("char*", 0x010EDEBD)[0] = value end, mGuiDisabled = 0x010EDEBD,
mGuiHalfSize = function(value) ffi.cast("char*", 0x010EDEBE)[0] = value end, mGuiHalfSize = 0x010EDEBE,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010EDEBF)[0] = value end, mFogOfWarOpenEverywhere = 0x010EDEBF,
mTrailerMode = function(value) ffi.cast("char*", 0x010EDEC0)[0] = value end, mTrailerMode = 0x010EDEC0,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010EDEC1)[0] = value end, mDayTimeRotationPause = 0x010EDEC1,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010EDEC2)[0] = value end, mPlayerNeverDies = 0x010EDEC2,
mFreezeAI = function(value) ffi.cast("char*", 0x010EDEC3)[0] = value end, mFreezeAI = 0x010EDEC3,
},
{_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,
}, },
}, },
}, },
@ -411,26 +122,24 @@ function Modification.SetMemoryOptions(memory)
-- Look up the tree and set options accordingly. -- Look up the tree and set options accordingly.
local level1 = lookup[DebugAPI.IsDevBuild()] local level1 = lookup[DebugGetIsDevBuild()]
level1 = level1 or {} if level1 == nil then return end
local level2 = level1[ffi.os] local level2 = level1[ffi.os]
level2 = level2 or {} if level2 == nil then return end
local level3 = {} local level3
for _, v in ipairs(level2) do for k, v in pairs(level2) do
if ffi.string(ffi.cast("char*", v._Offset)) == v._BuildString then if ffi.string(ffi.cast("char*", k)) == v._BuildString then
level3 = v level3 = v
break break
end end
end end
for k, v in pairs(memory) do for k, v in pairs(memory) do
local modFunc = level3[k] local address = level3[k]
if modFunc ~= nil then if address ~= nil then
modFunc(v) ffi.cast("char*", address)[0] = v
else
Message:ShowModificationUnsupported("processMemory", k, v)
end end
end end
end end
@ -448,13 +157,6 @@ function Modification.PatchFiles(patches)
end end
ModTextFileSetContent("data/shaders/post_final.frag", postFinal) ModTextFileSetContent("data/shaders/post_final.frag", postFinal)
end 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 end
---Returns tables with user requested game configuration changes. ---Returns tables with user requested game configuration changes.
@ -476,16 +178,12 @@ function Modification.RequiredChanges()
config["internal_size_h"] = tostring(Vec2(ModSettingGet("noita-mapcap.internal-resolution")).y) config["internal_size_h"] = tostring(Vec2(ModSettingGet("noita-mapcap.internal-resolution")).y)
config["backbuffer_width"] = config["window_w"] config["backbuffer_width"] = config["window_w"]
config["backbuffer_height"] = config["window_h"] config["backbuffer_height"] = config["window_h"]
config["fullscreen"] = "0"
magic["VIRTUAL_RESOLUTION_X"] = tostring(Vec2(ModSettingGet("noita-mapcap.virtual-resolution")).x) magic["VIRTUAL_RESOLUTION_X"] = tostring(Vec2(ModSettingGet("noita-mapcap.virtual-resolution")).x)
magic["VIRTUAL_RESOLUTION_Y"] = tostring(Vec2(ModSettingGet("noita-mapcap.virtual-resolution")).y) 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. -- 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["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_X"] = "-3"
magic["VIRTUAL_RESOLUTION_OFFSET_Y"] = "0" 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 else
-- Reset some values if there is no custom resolution requested. -- Reset some values if there is no custom resolution requested.
config["internal_size_w"] = "1280" config["internal_size_w"] = "1280"
@ -497,12 +195,13 @@ function Modification.RequiredChanges()
magic["VIRTUAL_RESOLUTION_OFFSET_Y"] = "-1" magic["VIRTUAL_RESOLUTION_OFFSET_Y"] = "-1"
end end
-- Always expect a fullscreen mode of 0 (windowed).
-- Capturing will not work in fullscreen.
config["fullscreen"] = "0"
-- Also disable screenshake. -- Also disable screenshake.
config["screenshake_intensity"] = "0" 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" magic["DRAW_PARALLAX_BACKGROUND"] = ModSettingGet("noita-mapcap.disable-background") and "0" or "1"
-- These magic numbers seem only to work in the dev build. -- These magic numbers seem only to work in the dev build.
@ -510,11 +209,6 @@ function Modification.RequiredChanges()
magic["DEBUG_PAUSE_BOX2D"] = ModSettingGet("noita-mapcap.disable-physics") and "1" or "0" 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" 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 if ModSettingGet("noita-mapcap.disable-postfx") then
patches.PostFinalConst = { patches.PostFinalConst = {
ENABLE_REFRACTION = false, ENABLE_REFRACTION = false,
@ -528,31 +222,13 @@ function Modification.RequiredChanges()
FOG_FOREGROUND_NIGHT = "vec4(0.0,0.0,0.0,1.0)", FOG_FOREGROUND_NIGHT = "vec4(0.0,0.0,0.0,1.0)",
FOG_BACKGROUND_NIGHT = "vec3(0.0,0.0,0.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 end
if ModSettingGet("noita-mapcap.disable-shaders-gui-ai") and DebugAPI.IsDevBuild() then if ModSettingGet("noita-mapcap.disable-shaders-gui-ai") then
memory["mPostFxDisabled"] = 1 memory["mPostFxDisabled"] = 1
memory["mGuiDisabled"] = 1 memory["mGuiDisabled"] = 1
memory["mFreezeAI"] = 1 memory["mFreezeAI"] = 1
end memory["mTrailerMode"] = 1 -- Is necessary for chunks to correctly load when DEBUG_PAUSE_GRID_UPDATE is enabled.
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 end
-- Disables or hides most of the UI. -- Disables or hides most of the UI.

View File

@ -8,7 +8,7 @@
----------------------- -----------------------
-- TODO: Wrap Noita utilities and wrap them into a table: https://stackoverflow.com/questions/9540732/loadfile-without-polluting-global-environment -- 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/utilities.lua`. require("utilities") -- Loads Noita's utilities from `data/scripts/lib/utilitites.lua`.
-------------------------- --------------------------
-- Load library modules -- -- Load library modules --
@ -146,7 +146,7 @@ function UI:_DrawMessages(messages)
end end
GuiLayoutEnd(gui) 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") local clicked = GuiImageButton(gui, self:_GenID(), 5, 0, "", "mods/noita-mapcap/files/ui-gfx/dismiss-8x8.png")
--GuiTooltip(gui, "Dismiss message", "") --GuiTooltip(gui, "Dismiss message", "")
if clicked then messages[key] = nil end if clicked then messages[key] = nil end
@ -196,7 +196,7 @@ function UI:Draw()
local gui = self.gui local gui = self.gui
-- Skip drawing if we are asked to do so. -- Skip drawing if we are asked to do so.
-- TODO: Find a way to suspend UI drawing, but still being able to receive events -- TODO: Find a way to susped UI drawing, but still being able to receive events
if self.suspendFrames and self.suspendFrames > 0 then self.suspendFrames = self.suspendFrames - 1 return end if self.suspendFrames and self.suspendFrames > 0 then self.suspendFrames = self.suspendFrames - 1 return end
self.suspendFrames = nil self.suspendFrames = nil

50
go.mod
View File

@ -1,43 +1,29 @@
module github.com/Dadido3/noita-mapcap module github.com/Dadido3/noita-mapcap
go 1.22 go 1.18
require ( require (
github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1 github.com/cheggaaa/pb/v3 v3.1.0
github.com/Dadido3/go-libwebp v0.3.0 github.com/coreos/go-semver v0.3.0
github.com/cheggaaa/pb/v3 v3.1.4 github.com/google/hilbert v0.0.0-20181122061418-320f2e35a565
github.com/coreos/go-semver v0.3.1 github.com/kbinani/screenshot v0.0.0-20210720154843-7d3a670d8329
github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/tdewolff/canvas v0.0.0-20231218015800-2ad5075e9362 golang.org/x/exp v0.0.0-20220713135740-79cabaa25d75
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 golang.org/x/image v0.0.0-20220617043117-41969df76e82
) )
require ( require (
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f // indirect github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1 // indirect
github.com/VividCortex/ewma v1.2.0 // indirect github.com/VividCortex/ewma v1.2.0 // indirect
github.com/benoitkugler/textlayout v0.3.0 // indirect github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/benoitkugler/textprocessing v0.0.3 // indirect github.com/fatih/color v1.13.0 // indirect
github.com/chzyer/readline v1.5.1 // indirect github.com/gen2brain/shm v0.0.0-20200228170931-49f9650110c5 // indirect
github.com/dsnet/compress v0.0.1 // indirect github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240 // 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/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/rivo/uniseg v0.4.4 // indirect github.com/rivo/uniseg v0.2.0 // indirect
github.com/tdewolff/minify/v2 v2.20.10 // indirect golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
github.com/tdewolff/parse/v2 v2.7.7 // indirect gopkg.in/yaml.v2 v2.4.0 // 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
View File

@ -1,117 +1,59 @@
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 h1:LejjvYg4tCW5HO7q/1nzPrprh47oUD9OUySQ29pDp5c=
github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1/go.mod h1:cnC/60IoLiDM0GhdKYJ6oO7AwpZe1IQfPnSKlAURgHw= github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1/go.mod h1:cnC/60IoLiDM0GhdKYJ6oO7AwpZe1IQfPnSKlAURgHw=
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f h1:l7moT9o/v/9acCWA64Yz/HDLqjcRTvc0noQACi4MsJw= github.com/VividCortex/ewma v1.1.1/go.mod h1:2Tkkvm3sRDVXaiyucHiACn4cqf7DpdyLvmxzcbUokwA=
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 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= github.com/cheggaaa/pb/v3 v3.1.0 h1:3uouEsl32RL7gTiQsuaXD4Bzbfl5tGztXGUvXbs4O04=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= github.com/cheggaaa/pb/v3 v3.1.0/go.mod h1:YjrevcBqadFDaGQKRdmZxTY42pXEqda48Ea3lt0K/BE=
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/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
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 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/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= github.com/gen2brain/shm v0.0.0-20200228170931-49f9650110c5 h1:Y5Q2mEwfzjMt5+3u70Gtw93ZOu2UuPeeeTBDntF7FoY=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/gen2brain/shm v0.0.0-20200228170931-49f9650110c5/go.mod h1:uF6rMu/1nvu+5DpiRLwusA6xB8zlkNoGzKn8lmYONUo=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/google/hilbert v0.0.0-20181122061418-320f2e35a565 h1:KBAlCAY6eLC44FiEwbzEbHnpVlw15iVM4ZK8QpRIp4U=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/google/hilbert v0.0.0-20181122061418-320f2e35a565/go.mod h1:xn6EodFfRzV6j8NXQRPjngeHWlrpOrsZPKuuLRThU1k=
github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY= github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240 h1:dy+DS31tGEGCsZzB45HmJJNHjur8GDgtRNX9U7HnSX4=
github.com/gen2brain/shm v0.1.0/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA= github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240/go.mod h1:3P4UH/k22rXyHIJD2w4h2XMqPX4Of/eySEZq9L6wqc4=
github.com/go-fonts/latin-modern v0.3.2 h1:M+Sq24Dp0ZRPf3TctPnG1MZxRblqyWC/cRUL9WmdaFc= github.com/kbinani/screenshot v0.0.0-20210720154843-7d3a670d8329 h1:qq2nCpSrXrmvDGRxW0ruW9BVEV1CN2a9YDOExdt+U0o=
github.com/go-fonts/latin-modern v0.3.2/go.mod h1:9odJt4NbRrbdj4UAMuLVd4zEukf6aAEKnDaQga0whqQ= github.com/kbinani/screenshot v0.0.0-20210720154843-7d3a670d8329/go.mod h1:2VPVQDR4wO7KXHwP+DAypEy67rXf+okUx2zjgpCxZw4=
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 h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 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/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= golang.org/x/exp v0.0.0-20220713135740-79cabaa25d75 h1:x03zeu7B2B11ySp+daztnwM5oBJ/8wGUSqrwcw9L0RA=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= golang.org/x/exp v0.0.0-20220713135740-79cabaa25d75/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
github.com/tdewolff/canvas v0.0.0-20231218015800-2ad5075e9362 h1:E9HkFtZcjoZQCaSyb2Finw4jhC0NWOJ2DCCoAMYrXLg= golang.org/x/image v0.0.0-20220617043117-41969df76e82 h1:KpZB5pUSBvrHltNEdK/tw0xlPeD13M6M6aGP32gKqiw=
github.com/tdewolff/canvas v0.0.0-20231218015800-2ad5075e9362/go.mod h1:hGxWCl1a3KdYh6pxYy9sa9jLAlmKLMeuCSCjjy39iVE= golang.org/x/image v0.0.0-20220617043117-41969df76e82/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
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-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-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/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.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= 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/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
star-tex.org/x/tex v0.4.0 h1:AXUwgpnHLCxZUWW3qrmjv6ezNhH3PjUVBuLLejz2cgU=
star-tex.org/x/tex v0.4.0/go.mod h1:w91ycsU/DkkCr7GWr60GPWqp3gn2U+6VX71T0o8k8qE=

BIN
images/example1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

BIN
images/example2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

View File

@ -1,4 +1,4 @@
-- Copyright (c) 2022-2024 David Vogel -- Copyright (c) 2022 David Vogel
-- --
-- This software is released under the MIT License. -- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT -- https://opensource.org/licenses/MIT
@ -108,6 +108,7 @@ end
---Called *every* time the game is about to start updating the world. ---Called *every* time the game is about to start updating the world.
function OnWorldPreUpdate() function OnWorldPreUpdate()
Message:CatchException("OnWorldPreUpdate", function() Message:CatchException("OnWorldPreUpdate", function()
-- Coroutines aren't run every frame in this lua sandbox, do it manually here. -- Coroutines aren't run every frame in this lua sandbox, do it manually here.
wake_up_waiting_threads(1) wake_up_waiting_threads(1)
@ -149,13 +150,10 @@ end
---@param isPaused boolean ---@param isPaused boolean
---@param isInventoryPause boolean ---@param isInventoryPause boolean
function OnPausedChanged(isPaused, isInventoryPause) function OnPausedChanged(isPaused, isInventoryPause)
Message:CatchException("OnPausedChanged", function()
-- Set some stuff based on mod settings. -- Set some stuff based on mod settings.
-- Normally this would be in `OnModSettingsChanged`, but that doesn't seem to be called. -- Normally this would be in `OnModSettingsChanged`, but that doesn't seem to be called.
local config, magic, memory, patches = Modification.RequiredChanges() local config, magic, memory, patches = Modification.RequiredChanges()
Modification.SetMemoryOptions(memory) Modification.SetMemoryOptions(memory)
end)
end end
---Will be called when the game is unpaused, if player changed any mod settings while the game was paused. ---Will be called when the game is unpaused, if player changed any mod settings while the game was paused.

View File

@ -62,7 +62,7 @@ func addPathToZip(zipWriter *zip.Writer, srcPath, archiveBasePath string, ignore
return err return err
} }
header.Name = filepath.ToSlash(archivePath) header.Name = archivePath
header.Method = zip.Deflate header.Method = zip.Deflate
writer, err := zipWriter.CreateHeader(header) writer, err := zipWriter.CreateHeader(header)

View File

@ -1,4 +1,4 @@
-- Copyright (c) 2022-2024 David Vogel -- Copyright (c) 2022 David Vogel
-- --
-- This software is released under the MIT License. -- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT -- https://opensource.org/licenses/MIT
@ -67,15 +67,15 @@ modSettings = {
{ {
id = "capture-mode", id = "capture-mode",
ui_name = "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.\n- Animation: Capture the screen frame by frame.", 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.",
value_default = "live", value_default = "live",
values = { { "live", "Live" }, { "area", "Area" }, { "spiral", "Spiral" }, { "animation", "Animation"} }, values = { { "live", "Live" }, { "area", "Area" }, { "spiral", "Spiral" } },
scope = MOD_SETTING_SCOPE_RUNTIME, scope = MOD_SETTING_SCOPE_RUNTIME,
}, },
{ {
id = "capture-mode-spiral-origin", id = "capture-mode-spiral-origin",
ui_name = " Origin", ui_name = " Origin",
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.", 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.",
value_default = "current", value_default = "current",
values = { { "current", "Current position" }, { "0", "World origin" }, { "custom", "Custom position" } }, values = { { "current", "Current position" }, { "0", "World origin" }, { "custom", "Custom position" } },
scope = MOD_SETTING_SCOPE_RUNTIME, scope = MOD_SETTING_SCOPE_RUNTIME,
@ -95,7 +95,7 @@ modSettings = {
ui_name = " Rectangle", ui_name = " Rectangle",
ui_description = "The area to be captured.\nSee documentation for more information.", ui_description = "The area to be captured.\nSee documentation for more information.",
value_default = "1x1", value_default = "1x1",
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" } }, values = { { "1x1", "Base layout" }, { "1x3", "Main World" }, { "1.5x3", "Extended" }, { "custom", "Custom" } },
scope = MOD_SETTING_SCOPE_RUNTIME, scope = MOD_SETTING_SCOPE_RUNTIME,
show_fn = function() return modSettings:GetNextValue("capture-mode") == "area" end, show_fn = function() return modSettings:GetNextValue("capture-mode") == "area" end,
}, },
@ -143,7 +143,7 @@ modSettings = {
value_default = "512", value_default = "512",
allowed_characters = "0123456789", allowed_characters = "0123456789",
scope = MOD_SETTING_SCOPE_RUNTIME, scope = MOD_SETTING_SCOPE_RUNTIME,
show_fn = function() return modSettings:GetNextValue("capture-mode") == "area" or modSettings:GetNextValue("capture-mode") == "spiral" end, show_fn = function() return modSettings:GetNextValue("capture-mode") ~= "live" end,
}, },
{ {
id = "pixel-scale", id = "pixel-scale",
@ -157,18 +157,6 @@ modSettings = {
scope = MOD_SETTING_SCOPE_RUNTIME, scope = MOD_SETTING_SCOPE_RUNTIME,
change_fn = roundChange, 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", id = "custom-resolution-live",
ui_name = "Use custom resolution", ui_name = "Use custom resolution",
@ -326,14 +314,6 @@ modSettings = {
value_default = false, value_default = false,
scope = MOD_SETTING_SCOPE_RUNTIME, 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,
},
} }
}, },
} }