Compare commits

..

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

27 changed files with 233 additions and 910 deletions

View File

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

View File

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

8
.gitignore vendored
View File

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

View File

@ -1,14 +1,12 @@
{
"cSpell.words": [
"aabb",
"acidflow",
"appdata",
"autosetup",
"backbuffer",
"basicfont",
"bytecode",
"cheggaaa",
"Dadido",
"dofile",
"dont",
"Downscales",
@ -28,7 +26,6 @@
"Lanczos",
"lann",
"ldflags",
"libwebp",
"linearize",
"longleg",
"lowram",
@ -41,30 +38,25 @@
"nfnt",
"Niccoli",
"noita",
"noitamap",
"Nolla",
"NXML",
"openseadragon",
"pixelated",
"polymorphed",
"promptui",
"rasterizer",
"Regen",
"respawn",
"runfast",
"savegames",
"schollz",
"screenshake",
"svenstaro",
"tcnksm",
"tdewolff",
"unmodded",
"unstitchable",
"upscaled",
"Vogel",
"Voronoi",
"webp",
"wepb",
"xmax",
"xmin",
"ymax",

View File

@ -1,10 +1,6 @@
# Capture areas
A list of available capture areas.
Other game-modes or mods may use a different biome setup, and therefore the coordinates shown here are not valid for them.
The values shown are for an unmodded `New Game` world.
The noita-mapcap mod will always automatically determine the required coordinates so that it correctly captures the base layout or multiples of it.
Coordinates are in in-game "virtual" or "world" pixels.
`Right` and `Bottom` coordinates are not included in the rectangle.
@ -68,16 +64,3 @@ Bottom = 41984
The end result will have a size of `51200 x 73728 pixels ~= 3775 megapixels`.
![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

@ -5,10 +5,7 @@ It works with the regular Noita build and the dev build.
![Title image](images/title.png)
Map captures created with this mod can be viewed on [map.runfast.stream] (may contain spoilers).
If you are interested in creating similar captures, or if you want to contribute your own captures to [map.runfast.stream], you can take a look at [github.com/acidflow-noita/noitamap].
There you'll find detailed step-by-step instructions on how to quickly capture large parts of the Noita world with as little visual glitches and other issues as possible.
A resulting image with nearly 3.8 gigapixels can be [seen here](https://easyzoom.com/image/223556) (May contain spoilers).
## System requirements
@ -58,7 +55,8 @@ To the top left of the window are 3 buttons:
- ![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.
- ![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 stitching tool
directory 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.
Start `stitch.exe` and proceed with the default values.
@ -81,10 +79,6 @@ After a few minutes the file `output.png` will be created.
- `Spiral`: Will capture the world in a spiral.
The center starting point of the spiral can either be your current viewport, the world center or some custom coordinates.
- `Animation`: Will capture an image sequence.
This will capture whatever you see frame by frame and stores it in the output folder by frame number.
You can't stitch the resulting images, but instead you can use something like ffmpeg to render the sequence into a video file.
### Advanced mod settings
- `World seed`: If non empty, this will set the next new game to this seed.
@ -152,9 +146,8 @@ The sliders are at their default values:
There is not a lot you can do about it:
- ~~You can try to increase the usable address space of your `.../Noita/noita_dev.exe` or `.../Noita/noita.exe` with [Large Address Aware] or a similar tool.
This will help with any crashes that are related to out of memory exceptions.~~
`Large Address Aware` is already set in newer Noita builds.
- You can try to increase the usable address space of your `.../Noita/noita_dev.exe` or `.../Noita/noita.exe` with [Large Address Aware](https://www.techpowerup.com/forums/threads/large-address-aware.112556/) or a similar tool.
This will help with any crashes that are related to out of memory exceptions.
- You can disable the replay recorder.
@ -196,28 +189,25 @@ This will cause fast moving objects to completely disappear, and slow moving obj
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
### I always get the warning "The resolution changed"
The message is to be expected when you change the resolution in the Noita settings without restarting the game.
But it can also happen when you accidentally select the console window (using `noita_dev.exe`).
The mod uses the active/selected window of the Noita process for capturing, which in this case would make it take screenshots of the console.
This can be fixed by selecting the Noita window again, or by switching back and forth between console and the main Noita window.
## Additional information
The resulting stitched images are quite big.
You can read [this comment](https://github.com/Dadido3/noita-mapcap/issues/7#issuecomment-723591552) that addresses how you can view, convert or even self-host your images.
You can use [github.com/Dadido3/noita-mapcap-openseadragon] if you want to host a browser based viewer on your own web space.
If you want to make your captures available to a wider audience, you should check out the [github.com/acidflow-noita/noitamap] project, which aims to make maps of all game modes (including mods) available to the public.
## Acknowledgements
This mod uses the [LuaNXML] 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.
## 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-2023 David Vogel
;
; This software is released under the MIT License.
; https://opensource.org/licenses/MIT
EnableExplicit
UsePNGImageEncoder()
Declare Worker(*Dummy)
@ -18,18 +16,12 @@ Structure QueueElement
EndStructure
Structure GLViewportDims
x.i
y.i
width.i
height.i
x.i
y.i
width.i
height.i
EndStructure
Structure WorkerInfo
workerNumber.i
EndStructure
#Workers = 8
; Returns the size of the main OpenGL rendering output.
ProcedureDLL GetGLViewportSize(*dims.GLViewportDims)
If Not *dims
@ -46,10 +38,10 @@ ProcedureDLL GetRect(*rect.RECT)
If Not *rect
ProcedureReturn #False
EndIf
Protected dims.GLViewportDims
glGetIntegerv_(#GL_VIEWPORT, dims)
*rect\left = dims\x
*rect\top = dims\y
*rect\right = dims\x + dims\width
@ -65,16 +57,13 @@ ProcedureDLL AttachProcess(Instance)
CreateDirectory("mods/noita-mapcap/output/")
Static Dim WorkerInfos.WorkerInfo(#Workers-1)
Protected i
For i = 0 To #Workers-1
WorkerInfos(i)\workerNumber = i
CreateThread(@Worker(), @WorkerInfos(i))
For i = 1 To 6
CreateThread(@Worker(), #Null)
Next
EndProcedure
Procedure Worker(*workerInfo.WorkerInfo)
Protected img, x, y, sx, sy
Procedure Worker(*Dummy)
Protected img, x, y
Repeat
WaitSemaphore(Semaphore)
@ -93,12 +82,8 @@ Procedure Worker(*workerInfo.WorkerInfo)
ResizeImage(img, sx, sy)
EndIf
; Save image temporary, and only move it once it's fully exported.
; This prevents images getting corrupted when the main process crashes.
If SaveImage(img, "mods/noita-mapcap/output/worker_" + *workerInfo\workerNumber + ".tmp", #PB_ImagePlugin_PNG)
RenameFile("mods/noita-mapcap/output/worker_" + *workerInfo\workerNumber + ".tmp", "mods/noita-mapcap/output/" + x + "," + y + ".png")
; We can't really do anything when either SaveImage or RenameFile fails, so just silently fail.
EndIf
SaveImage(img, "mods/noita-mapcap/output/" + x + "," + y + ".png", #PB_ImagePlugin_PNG)
;SaveImage(img, "" + x + "," + y + ".png", #PB_ImagePlugin_PNG) ; Test
FreeImage(img)
ForEver
@ -114,8 +99,6 @@ ProcedureDLL Capture(*capRect.RECT, x.l, y.l, sx.l, sy.l)
ProcedureReturn #False
EndIf
Protected imageID, hDC, *pixelBuffer
; Limit the desired capture area to the actual client area of the viewport.
If *capRect\left < 0 : *capRect\left = 0 : EndIf
If *capRect\top < 0 : *capRect\top = 0 : EndIf
@ -123,28 +106,38 @@ ProcedureDLL Capture(*capRect.RECT, x.l, y.l, sx.l, sy.l)
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
Protected capHeight = *capRect\bottom - *capRect\top
imageID = CreateImage(#PB_Any, capWidth, capHeight)
If Not imageID
ProcedureReturn #False
EndIf
;Protected *pixelBuf = AllocateMemory(3 * width * height)
hDC = StartDrawing(ImageOutput(imageID))
If Not hDC
FreeImage(imageID)
ProcedureReturn #False
EndIf
*pixelBuffer = DrawingBuffer()
glReadPixels_(*capRect\left, *capRect\top, capWidth, capHeight, #GL_BGR_EXT, #GL_UNSIGNED_BYTE, *pixelBuffer)
If glGetError_() <> #GL_NO_ERROR
StopDrawing()
FreeImage(imageID)
ProcedureReturn #False
EndIf
glReadPixels_(*capRect\left, *capRect\top, capWidth, capHeight, #GL_BGR_EXT, #GL_UNSIGNED_BYTE, *pixelBuffer)
;For y = 0 To *capRect\height - 1
; *Line.Pixel = Buffer + Pitch * y
;
; For x = 0 To *capRect\width - 1
;
; *Line\Pixel = ColorTable(pos2) ; Write the pixel directly to the memory !
; *Line+Offset
;
; ; You can try with regular plot to see the speed difference
; ; Plot(x, y, ColorTable(pos2))
; Next
; Next
StopDrawing()
@ -178,12 +171,11 @@ EndProcedure
; IDE Options = PureBasic 6.04 LTS (Windows - x64)
; ExecutableFormat = Shared dll
; CursorPosition = 99
; FirstLine = 72
; CursorPosition = 126
; FirstLine = 98
; Folding = -
; Optimizer
; EnableThread
; EnableXP
; Executable = capture.dll
; DisableDebugger
; Compiler = PureBasic 6.04 LTS - C Backend (Windows - x86)
; Compiler = PureBasic 6.04 LTS (Windows - x86)

Binary file not shown.

View File

@ -36,7 +36,7 @@ example list of files:
If set to 1, only the newest tile will be used for any resulting pixel.
Use 1 to prevent ghosting and blurry objects.
- `input string`
The source path of the image tiles to be stitched. Defaults to "./..//..//output"
The source path of the image tiles to be stitched. Defaults to "./..//..//output")
- `entities string`
The path to the `entities.json` file. This contains Noita specific entity data. Defaults to "./../../output/entities.json".
- `player-path string`
@ -48,8 +48,6 @@ example list of files:
The size of the resulting deep zoom image (DZI) tiles in pixels. Defaults to 512.
- `dzi-tile-overlap`
The number of additional pixels around every deep zoom image (DZI) tile. Defaults to 2.
- `webp-level`
Compression level of WebP files, from 0 (fast) to 9 (slow, best compression). Defaults to 8.
- `xmax int`
Right bound of the output rectangle. This coordinate is not included in the output.
- `xmin int`

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.
// https://opensource.org/licenses/MIT
@ -8,7 +8,6 @@ package main
import (
"image"
"image/color"
"image/draw"
"math"
"sort"
)
@ -107,7 +106,7 @@ func (b BlendMethodVoronoi) Draw(tiles []*ImageTile, destImage *image.RGBA) {
images = append(images, tile.GetImage())
}
// Create color variables reused every pixel.
// Create arrays to be reused every pixel.
var col color.RGBA
var centerDistSqrMin int
@ -148,17 +147,3 @@ func (b BlendMethodVoronoi) Draw(tiles []*ImageTile, destImage *image.RGBA) {
}
}
}
// 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

@ -12,13 +12,9 @@ import (
"log"
"os"
"path/filepath"
"runtime"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/cheggaaa/pb/v3"
)
type DZI struct {
@ -82,10 +78,6 @@ func (d DZI) ExportDZIDescriptor(outputPath string) error {
Width string
Height string
}
TopLeft struct {
X string
Y string
}
}
}
@ -95,57 +87,15 @@ func (d DZI) ExportDZIDescriptor(outputPath string) error {
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 {
func (d DZI) ExportDZITiles(outputDir string) 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.
@ -164,7 +114,6 @@ func (d DZI) ExportDZITiles(outputDir string, bar *pb.ProgressBar, webPLevel int
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)
@ -172,16 +121,11 @@ func (d DZI) ExportDZITiles(outputDir string, bar *pb.ProgressBar, webPLevel int
rect = rect.Inset(-d.overlap)
img := stitchedImage.SubStitchedImage(rect)
filePath := filepath.Join(levelBasePath, fmt.Sprintf("%d_%d%s", iX, iY, d.fileExtension))
if err := exportWebPSilent(img, filePath); err != nil {
return fmt.Errorf("failed to export WebP: %w", err)
}
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)
}()
scaleDivider := 2
imageTiles = append(imageTiles, ImageTile{
fileName: filePath,
modTime: time.Now(),
@ -193,12 +137,11 @@ func (d DZI) ExportDZITiles(outputDir string, bar *pb.ProgressBar, webPLevel int
})
}
}
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)
stitchedImage, err = NewStitchedImage(imageTiles, imageTiles.Bounds(), BlendMethodMedian{BlendTileLimit: 0}, 128, nil)
if err != nil {
return fmt.Errorf("failed to run NewStitchedImage(): %w", err)
}

View File

@ -1,4 +1,4 @@
// Copyright (c) 2023-2024 David Vogel
// Copyright (c) 2023 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -10,11 +10,9 @@ import (
"os"
"path/filepath"
"strings"
"github.com/cheggaaa/pb/v3"
)
func exportDZIStitchedImage(stitchedImage *StitchedImage, outputPath string, bar *pb.ProgressBar, dziTileSize, dziOverlap int, webPLevel int) error {
func exportDZI(stitchedImage *StitchedImage, outputPath string, dziTileSize, dziOverlap int) error {
descriptorPath := outputPath
extension := filepath.Ext(outputPath)
outputTilesPath := strings.TrimSuffix(outputPath, extension) + "_files"
@ -32,7 +30,7 @@ func exportDZIStitchedImage(stitchedImage *StitchedImage, outputPath string, bar
}
// Export DZI tiles.
if err := dzi.ExportDZITiles(outputTilesPath, bar, webPLevel); err != nil {
if err := dzi.ExportDZITiles(outputTilesPath); err != nil {
return fmt.Errorf("failed to export DZI tiles: %w", err)
}

View File

@ -1,4 +1,4 @@
// Copyright (c) 2023-2024 David Vogel
// Copyright (c) 2023 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -11,44 +11,15 @@ import (
"image/jpeg"
"log"
"os"
"time"
"github.com/cheggaaa/pb/v3"
)
func exportJPEGStitchedImage(stitchedImage *StitchedImage, outputPath string, bar *pb.ProgressBar) error {
func exportJPEG(stitchedImage image.Image, outputPath string) 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)
return exportJPEGSilent(stitchedImage, outputPath)
}
func exportJPEG(img image.Image, outputPath string) error {
func exportJPEGSilent(stitchedImage image.Image, outputPath string) error {
f, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
@ -59,7 +30,7 @@ func exportJPEG(img image.Image, outputPath string) error {
Quality: 80,
}
if err := jpeg.Encode(f, img, options); err != nil {
if err := jpeg.Encode(f, stitchedImage, options); err != nil {
return fmt.Errorf("failed to encode image %q: %w", outputPath, err)
}

View File

@ -1,4 +1,4 @@
// Copyright (c) 2023-2024 David Vogel
// Copyright (c) 2023 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -11,44 +11,15 @@ import (
"image/png"
"log"
"os"
"time"
"github.com/cheggaaa/pb/v3"
)
func exportPNGStitchedImage(stitchedImage *StitchedImage, outputPath string, bar *pb.ProgressBar) error {
func exportPNG(stitchedImage image.Image, outputPath string) 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)
return exportPNGSilent(stitchedImage, outputPath)
}
func exportPNG(img image.Image, outputPath string) error {
func exportPNGSilent(stitchedImage image.Image, outputPath string) error {
f, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
@ -59,7 +30,7 @@ func exportPNG(img image.Image, outputPath string) error {
CompressionLevel: png.DefaultCompression,
}
if err := encoder.Encode(f, img); err != nil {
if err := encoder.Encode(f, stitchedImage); err != nil {
return fmt.Errorf("failed to encode image %q: %w", outputPath, err)
}

View File

@ -10,46 +10,18 @@ import (
"image"
"log"
"os"
"time"
"github.com/Dadido3/go-libwebp/webp"
"github.com/cheggaaa/pb/v3"
"github.com/chai2010/webp"
)
func exportWebPStitchedImage(stitchedImage *StitchedImage, outputPath string, bar *pb.ProgressBar, webPLevel int) error {
func exportWebP(stitchedImage image.Image, outputPath string) 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)
return exportWebPSilent(stitchedImage, outputPath)
}
func exportWebP(img image.Image, outputPath string, webPLevel int) error {
bounds := img.Bounds()
func exportWebPSilent(stitchedImage image.Image, outputPath string) error {
bounds := stitchedImage.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())
}
@ -60,12 +32,7 @@ func exportWebP(img image.Image, outputPath string, webPLevel int) error {
}
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 {
if err = webp.Encode(f, stitchedImage, &webp.Options{Lossless: true}); err != nil {
return fmt.Errorf("failed to encode image %q: %w", outputPath, err)
}

View File

@ -8,7 +8,6 @@ package main
import (
"fmt"
"image"
"image/draw"
_ "image/png"
"log"
"os"
@ -128,16 +127,9 @@ func (it *ImageTile) GetImage() *image.RGBA {
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)
imgRGBA, ok := img.(*image.RGBA)
if !ok {
log.Printf("Expected an RGBA image for %q, got %T instead.", it.fileName, img)
return nil
}

View File

@ -12,6 +12,7 @@ import (
"log"
"path/filepath"
"strings"
"sync"
"time"
"github.com/1lann/promptui"
@ -26,7 +27,6 @@ var flagScaleDivider = flag.Int("divide", 1, "A downscaling factor. 2 will produ
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.")
@ -287,35 +287,11 @@ func main() {
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")
}
startTime := time.Now()
return nil
},
}
result, err := prompt.Run()
if err != nil {
log.Panicf("Error while getting user input: %v.", err)
}
fmt.Sscanf(result, "%d", flagWebPLevel)
}
bar := pb.Full.New(0)
var wg sync.WaitGroup
done := make(chan struct{})
blendMethod := BlendMethodMedian{
BlendTileLimit: *flagBlendTileLimit, // Limit median blending to the n newest tiles by file modification time.
@ -325,31 +301,53 @@ func main() {
if err != nil {
log.Panicf("NewStitchedImage() failed: %v.", err)
}
_, max := stitchedImage.Progress()
bar.SetTotal(int64(max)).Start().SetRefreshRate(250 * time.Millisecond)
bar := pb.Full.New(0)
// Query progress and draw progress bar.
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(250 * time.Millisecond)
for {
select {
case <-done:
value, _ := stitchedImage.Progress()
bar.SetCurrent(int64(value))
bar.Finish()
return
case <-ticker.C:
value, _ := stitchedImage.Progress()
bar.SetCurrent(int64(value))
}
}
}()
switch fileExtension {
case ".png":
if err := exportPNGStitchedImage(stitchedImage, *flagOutputPath, bar); err != nil {
if err := exportPNG(stitchedImage, *flagOutputPath); err != nil {
log.Panicf("Export of PNG file failed: %v", err)
}
case ".jpg", ".jpeg":
if err := exportJPEGStitchedImage(stitchedImage, *flagOutputPath, bar); err != nil {
if err := exportJPEG(stitchedImage, *flagOutputPath); err != nil {
log.Panicf("Export of JPEG file failed: %v", err)
}
case ".webp":
if err := exportWebPStitchedImage(stitchedImage, *flagOutputPath, bar, *flagWebPLevel); err != nil {
if err := exportWebP(stitchedImage, *flagOutputPath); err != nil {
log.Panicf("Export of WebP file failed: %v", err)
}
case ".dzi":
if err := exportDZIStitchedImage(stitchedImage, *flagOutputPath, bar, *flagDZITileSize, *flagDZIOverlap, *flagWebPLevel); err != nil {
if err := exportDZI(stitchedImage, *flagOutputPath, *flagDZITileSize, *flagDZIOverlap); 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()))
done <- struct{}{}
wg.Wait()
log.Printf("Created output in %v.", time.Since(startTime))
//fmt.Println("Press the enter key to terminate the console screen!")
//fmt.Scanln()

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.
// https://opensource.org/licenses/MIT
@ -9,7 +9,6 @@ import (
"fmt"
"image"
"os"
"sync"
)
// QuickSelect returns the kth smallest element of the given unsorted list.
@ -97,44 +96,3 @@ func DivideCeil(a, b int) int {
return temp
}
// https://gist.github.com/cstockton/d611ced26bb6b4d3f7d4237abb8613c4
type LimitGroup struct {
wg sync.WaitGroup
mu *sync.Mutex
c *sync.Cond
l, n int
}
func NewLimitGroup(n int) *LimitGroup {
mu := new(sync.Mutex)
return &LimitGroup{
mu: mu,
c: sync.NewCond(mu),
l: n,
n: n,
}
}
func (lg *LimitGroup) Add(delta int) {
lg.mu.Lock()
defer lg.mu.Unlock()
if delta > lg.l {
panic(`LimitGroup: delta must not exceed limit`)
}
for lg.n < 1 {
lg.c.Wait()
}
lg.n -= delta
lg.wg.Add(delta)
}
func (lg *LimitGroup) Done() {
lg.mu.Lock()
defer lg.mu.Unlock()
lg.n++
lg.c.Signal()
lg.wg.Done()
}
func (lg *LimitGroup) Wait() { lg.wg.Wait() }

View File

@ -32,7 +32,7 @@ Capture.PlayerPathCapturingCtx = Capture.PlayerPathCapturingCtx or ProcessRunner
---Returns a capturing rectangle in window coordinates, and also the world coordinates for the same rectangle.
---The rectangle is sized and placed in a way that aligns as pixel perfect as possible with the world coordinates.
---@param pos Vec2? -- 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 bottomRightCapture
---@return Vec2 topLeftWorld
@ -53,13 +53,12 @@ end
---This will block until all chunks in the virtual rectangle are loaded.
---
---Don't set `ensureLoaded` to true when `pos` is nil!
---@param pos Vec2? -- Position of the viewport center in world coordinates. If set to nil, the viewport will not be modified.
---@param ensureLoaded boolean? -- If true, the function will wait until all chunks in the virtual rectangle are loaded.
---@param dontOverwrite boolean? -- If true, the function will abort if there is already a file with the same coordinates.
---@param ctx ProcessRunnerCtx? -- The process runner context this runs in.
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
---@param captureDelay number? -- The number of additional frames to wait before a screen capture.
local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPixelScale, captureDelay)
---@param pos Vec2|nil -- Position of the viewport center in world coordinates. If set to nil, the viewport will not be modified.
---@param ensureLoaded boolean|nil -- If true, the function will wait until all chunks in the virtual rectangle are loaded.
---@param dontOverwrite boolean|nil -- If true, the function will abort if there is already a file with the same coordinates.
---@param ctx ProcessRunnerCtx|nil -- The process runner context this runs in.
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPixelScale)
if outputPixelScale == 0 or outputPixelScale == nil then
outputPixelScale = Coords:PixelScale()
end
@ -80,23 +79,10 @@ local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPi
return
end
-- Reset the count for the "Waiting for x frames." message in the UI.
if ctx then ctx.state.WaitFrames = 0 end
-- Wait some additional frames.
-- We will shake the screen a little bit so that Noita generates/populates chunks.
if captureDelay and captureDelay > 0 then
for _ = 1, captureDelay do
if pos then CameraAPI.SetPos(pos + Vec2(math.random(-1, 1), math.random(-1, 1))) end
wait(0)
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 1 end
end
end
if pos then CameraAPI.SetPos(pos) end
if ensureLoaded then
local delayFrames = 0
if ctx then ctx.state.WaitFrames = delayFrames end
repeat
-- Prematurely stop capturing if that is requested by the context.
if ctx and ctx:IsStopping() then return end
@ -106,33 +92,19 @@ local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPi
if pos then CameraAPI.SetPos(pos + Vec2(math.random(-10, 10), math.random(-10, 10))) end
wait(0)
delayFrames = delayFrames + 1
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 1 end
if ctx then ctx.state.WaitFrames = delayFrames end
if pos then CameraAPI.SetPos(pos) end
end
if delayFrames > 600 then
-- Shaking wasn't enough, we will just move somewhere else an try again.
if pos then CameraAPI.SetPos(pos + Vec2(math.random(-4000, 4000), math.random(-4000, 4000))) end
wait(50)
delayFrames = delayFrames + 50
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 50 end
if pos then CameraAPI.SetPos(pos) end
wait(10)
delayFrames = delayFrames + 10
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 10 end
end
wait(0)
delayFrames = delayFrames + 1
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 1 end
if ctx then ctx.state.WaitFrames = delayFrames end
local topLeftBounds, bottomRightBounds = CameraAPI:Bounds()
until DoesWorldExistAt(topLeftBounds.x, topLeftBounds.y, bottomRightBounds.x, bottomRightBounds.y)
-- Chunks are loaded and will be drawn on the *next* frame.
end
if ctx then ctx.state.WaitFrames = 0 end
-- Suspend UI drawing for 1 frame.
UI:SuspendDrawing(1)
@ -166,37 +138,6 @@ local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPi
MonitorStandby.ResetTimer()
end
---Captures a screenshot of the current viewport.
---This is used to capture animations, therefore the resulting image may not be suitable for stitching.
---@param outputPixelScale number? The resulting image pixel to world pixel ratio.
---@param frameNumber integer The frame number of the animation.
local function captureScreenshotAnimation(outputPixelScale, frameNumber)
if outputPixelScale == 0 or outputPixelScale == nil then
outputPixelScale = Coords:PixelScale()
end
local rectTopLeft, rectBottomRight = ScreenCapture.GetRect()
if not rectTopLeft or not rectBottomRight then
error(string.format("couldn't determine capturing rectangle"))
end
if Coords:InternalRectSize() ~= rectBottomRight - rectTopLeft then
error(string.format("internal rectangle size seems to have changed from %s to %s", Coords:InternalRectSize(), rectBottomRight - rectTopLeft))
end
local topLeftWorld, bottomRightWorld = Coords:ToWorld(rectTopLeft), Coords:ToWorld(rectBottomRight)
---We will use this to get our fame number into the filename.
---@type Vec2
local outputTopLeft = Vec2(frameNumber, 0)
if not ScreenCapture.Capture(rectTopLeft, rectBottomRight, outputTopLeft, (bottomRightWorld - topLeftWorld) * outputPixelScale) then
error(string.format("failed to capture screenshot"))
end
-- Reset monitor and PC standby every screenshot.
MonitorStandby.ResetTimer()
end
---Map capture process runner context error handler callback. Just rolls off the tongue.
---@param err string
---@param scope "init"|"do"|"end"
@ -209,9 +150,8 @@ end
---Use `Capture.MapCapturingCtx` to stop, control or view the progress.
---@param origin Vec2 -- Center of the spiral in world pixels.
---@param captureGridSize number -- The grid size in world pixels.
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
---@param captureDelay number? -- The number of additional frames to wait before a screen capture.
function Capture:StartCapturingSpiral(origin, captureGridSize, outputPixelScale, captureDelay)
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
function Capture:StartCapturingSpiral(origin, captureGridSize, outputPixelScale)
-- Create file that signals that there are files in the output directory.
local file = io.open("mods/noita-mapcap/output/nonempty", "a")
@ -235,23 +175,23 @@ function Capture:StartCapturingSpiral(origin, captureGridSize, outputPixelScale,
repeat
-- +x
for _ = 1, i, 1 do
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
captureScreenshot(pos, true, true, ctx, outputPixelScale)
pos:Add(Vec2(captureGridSize, 0))
end
-- +y
for _ = 1, i, 1 do
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
captureScreenshot(pos, true, true, ctx, outputPixelScale)
pos:Add(Vec2(0, captureGridSize))
end
i = i + 1
-- -x
for _ = 1, i, 1 do
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
captureScreenshot(pos, true, true, ctx, outputPixelScale)
pos:Add(Vec2(-captureGridSize, 0))
end
-- -y
for _ = 1, i, 1 do
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
captureScreenshot(pos, true, true, ctx, outputPixelScale)
pos:Add(Vec2(0, -captureGridSize))
end
i = i + 1
@ -271,29 +211,24 @@ end
---Starts the capturing process of the given area using a hilbert curve.
---Use `Capture.MapCapturingCtx` to stop, control or view the process.
---@param topLeft Vec2 -- Top left of the to be captured rectangle.
---@param bottomRight Vec2 -- Non 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 outputPixelScale number? -- The resulting image pixel to world pixel ratio.
---@param captureDelay number? -- The number of additional frames to wait before a screen capture.
function Capture:StartCapturingAreaHilbert(topLeft, bottomRight, captureGridSize, outputPixelScale, captureDelay)
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
function Capture:StartCapturingAreaHilbert(topLeft, bottomRight, captureGridSize, outputPixelScale)
-- 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.
---The rectangle in grid coordinates.
---@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
local gridSize = gridBottomRight - gridTopLeft
@ -322,8 +257,8 @@ function Capture:StartCapturingAreaHilbert(topLeft, bottomRight, captureGridSize
---Position in world coordinates.
---@type Vec2
local pos = (hilbertPos + gridTopLeft) * captureGridSize
pos:Add(captureOffset) -- Move to center of grid cell.
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
pos:Add(Vec2(captureGridSize / 2, captureGridSize / 2)) -- Move to center of grid cell.
captureScreenshot(pos, true, true, ctx, outputPixelScale)
ctx.state.Current = ctx.state.Current + 1
end
@ -344,29 +279,20 @@ 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 bottomRight Vec2 -- Non included bottom left 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)
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
function Capture:StartCapturingAreaScan(topLeft, bottomRight, captureGridSize, outputPixelScale)
-- 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.
---The rectangle in grid coordinates.
---@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.
---Size of the rectangle in grid coordinates.
---@type Vec2
local gridSize = gridBottomRight - gridTopLeft
@ -388,8 +314,8 @@ function Capture:StartCapturingAreaScan(topLeft, bottomRight, captureGridSize, o
---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)
pos:Add(Vec2(captureGridSize / 2, captureGridSize / 2)) -- Move to center of grid cell.
captureScreenshot(pos, true, true, ctx, outputPixelScale)
ctx.state.Current = ctx.state.Current + 1
end
end
@ -407,7 +333,7 @@ end
---Starts the live capturing 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)
---Queries the mod settings for the live capture parameters.
@ -445,7 +371,7 @@ function Capture:StartCapturingLive(outputPixelScale)
if oldPos then distanceSqr = CameraAPI.GetPos():DistanceSqr(oldPos) else distanceSqr = math.huge end
until ctx:IsStopping() or ((delayFrames >= interval or distanceSqr >= maxDistanceSqr) and distanceSqr >= minDistanceSqr)
captureScreenshot(nil, false, false, ctx, outputPixelScale, nil)
captureScreenshot(nil, false, false, ctx, outputPixelScale)
oldPos = CameraAPI.GetPos()
until ctx:IsStopping()
end
@ -461,7 +387,7 @@ function Capture:StartCapturingLive(outputPixelScale)
end
---Gathers all entities on the screen (around x, y within radius), serializes them, appends them into entityFile and/or modifies those entities.
---@param file file*?
---@param file file*|nil
---@param modify boolean
---@param x number
---@param y number
@ -584,7 +510,7 @@ local function captureModifyEntities(file, modify, x, y, radius)
end
---
---@return file*?
---@return file*|nil
local function createOrOpenEntityCaptureFile()
-- Make sure the file exists.
local file = io.open("mods/noita-mapcap/output/entities.json", "a")
@ -644,7 +570,7 @@ function Capture:StartCapturingEntities(store, modify)
end
---Writes the current player position and other stats onto disk.
---@param file file*?
---@param file file*|nil
---@param pos Vec2
---@param oldPos Vec2
---@param hp number
@ -677,7 +603,7 @@ local function writePlayerPathEntry(file, pos, oldPos, hp, maxHP, polymorphed)
end
---
---@return file*?
---@return file*|nil
local function createOrOpenPlayerPathCaptureFile()
-- Make sure the file exists.
local file = io.open("mods/noita-mapcap/output/player-path.json", "a")
@ -692,8 +618,8 @@ 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.
---@param interval integer|nil -- Wait time between captures in frames.
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
function Capture:StartCapturingPlayerPath(interval, outputPixelScale)
interval = interval or 20
@ -721,7 +647,7 @@ function Capture:StartCapturingPlayerPath(interval, outputPixelScale)
-- 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?
---@type NoitaEntity|nil
local playerEntity
-- Try to find the regular player entity.
@ -781,56 +707,6 @@ function Capture:StartCapturingPlayerPath(interval, outputPixelScale)
self.PlayerPathCapturingCtx:Run(handleInit, handleDo, handleEnd, handleErr)
end
---Starts to capture an animation.
---This stores sequences of images that can't be stitched, but can be rendered into a video instead.
---Use `Capture.MapCapturingCtx` to stop, control or view the process.
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
function Capture:StartCapturingAnimation(outputPixelScale)
---Queries the mod settings for the live capture parameters.
---@return integer interval -- The interval length in frames.
local function querySettings()
local interval = 1--tonumber(ModSettingGet("noita-mapcap.live-interval")) or 30
return interval
end
-- Create file that signals that there are files in the output directory.
local file = io.open("mods/noita-mapcap/output/nonempty", "a")
if file ~= nil then file:close() end
---Process main callback.
---@param ctx ProcessRunnerCtx
local function handleDo(ctx)
Modification.SetCameraFree(false)
local frame = 0
repeat
local interval = querySettings()
-- Wait until we are allowed to take a new screenshot.
local delayFrames = 0
repeat
wait(0)
delayFrames = delayFrames + 1
until ctx:IsStopping() or delayFrames >= interval
captureScreenshotAnimation(outputPixelScale, frame)
frame = frame + 1
until ctx:IsStopping()
end
---Process end callback.
---@param ctx ProcessRunnerCtx
local function handleEnd(ctx)
Modification.SetCameraFree()
end
-- Run process, if there is no other running right now.
self.MapCapturingCtx:Run(nil, handleDo, handleEnd, mapCapturingCtxErrHandler)
end
---Starts the capturing process based on user/mod settings.
function Capture:StartCapturing()
Message:CatchException("Capture:StartCapturing", function()
@ -838,26 +714,21 @@ function Capture:StartCapturing()
local mode = ModSettingGet("noita-mapcap.capture-mode")
local outputPixelScale = ModSettingGet("noita-mapcap.pixel-scale")
local captureGridSize = tonumber(ModSettingGet("noita-mapcap.grid-size"))
local captureDelay = tonumber(ModSettingGet("noita-mapcap.capture-delay"))
if mode == "live" then
self:StartCapturingLive(outputPixelScale)
self:StartCapturingPlayerPath(5, outputPixelScale) -- Capture player path with an interval of 5 frames.
elseif mode == "animation" then
self:StartCapturingAnimation(outputPixelScale)
elseif mode == "area" then
local area = ModSettingGet("noita-mapcap.area")
if area == "custom" then
local topLeft = Vec2(ModSettingGet("noita-mapcap.area-top-left"))
local bottomRight = Vec2(ModSettingGet("noita-mapcap.area-bottom-right"))
self:StartCapturingAreaScan(topLeft, bottomRight, captureGridSize, outputPixelScale, captureDelay)
self:StartCapturingAreaScan(topLeft, bottomRight, captureGridSize, outputPixelScale)
else
---@type fun():Vec2, Vec2
local predefinedAreaFunction = Config.CaptureArea[area]
if predefinedAreaFunction then
local topLeft, bottomRight = predefinedAreaFunction()
self:StartCapturingAreaScan(topLeft, bottomRight, captureGridSize, outputPixelScale, captureDelay)
local predefinedArea = Config.CaptureArea[area]
if predefinedArea then
self:StartCapturingAreaScan(predefinedArea.TopLeft, predefinedArea.BottomRight, captureGridSize, outputPixelScale)
else
Message:ShowRuntimeError("PredefinedArea", string.format("Unknown predefined capturing area %q", tostring(area)))
end
@ -866,13 +737,13 @@ function Capture:StartCapturing()
local origin = ModSettingGet("noita-mapcap.capture-mode-spiral-origin")
if origin == "custom" then
local originVec = Vec2(ModSettingGet("noita-mapcap.capture-mode-spiral-origin-vector"))
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale, captureDelay)
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale)
elseif origin == "0" then
local originVec = Vec2(0, 0)
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale, captureDelay)
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale)
elseif origin == "current" then
local originVec = CameraAPI:GetPos()
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale, captureDelay)
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale)
else
Message:ShowRuntimeError("SpiralOrigin", string.format("Unknown spiral origin %q", tostring(origin)))
end

View File

@ -1,4 +1,4 @@
-- Copyright (c) 2019-2024 David Vogel
-- Copyright (c) 2019-2023 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
@ -62,12 +62,12 @@ function Check:Regular(interval)
-- Check if we have the required settings.
local config, magic, patches = Modification.RequiredChanges()
if config["fullscreen"] then
local expected = tonumber(config["fullscreen"])
if expected ~= Coords.FullscreenMode then
Message:ShowSetNoitaSettings(Modification.AutoSet, string.format("Fullscreen mode %s. Expected %s.", Coords.FullscreenMode, expected))
end
end
--if config["fullscreen"] then
-- local expected = tonumber(config["fullscreen"])
-- if expected ~= Coords.FullscreenMode then
-- Message:ShowSetNoitaSettings(Modification.AutoSet, string.format("Fullscreen mode %s. Expected %s.", Coords.FullscreenMode, expected))
-- end
--end
if config["window_w"] and config["window_h"] then
local expected = Vec2(tonumber(config["window_w"]), tonumber(config["window_h"]))
if expected ~= Coords.WindowResolution then
@ -86,12 +86,6 @@ function Check:Regular(interval)
Message:ShowSetNoitaSettings(Modification.AutoSet, string.format("Screen shake intensity is %s, expected %s.", self.StartupConfig.screenshake_intensity, expected))
end
end
if config["application_rendered_cursor"] then
local expected = config.application_rendered_cursor
if expected ~= self.StartupConfig.application_rendered_cursor then
Message:ShowSetNoitaSettings(Modification.AutoSet, string.format("Application rendered cursor is %s, expected %s.", self.StartupConfig.application_rendered_cursor, expected))
end
end
-- 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
@ -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.
local mode = ModSettingGet("noita-mapcap.capture-mode")
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(
"The virtual resolution is smaller than the capture grid size.",
"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.
-- https://opensource.org/licenses/MIT
local NXML = require("luanxml.nxml")
local Vec2 = require("noita-api.vec2")
-- List of components that will be disabled on every encountered entity.
@ -28,65 +27,22 @@ Config.ComponentsToDisable = {
--"AudioComponent",
}
local CHUNK_SIZE = 512
---Returns the rectangle of the base area as two vectors.
---@return Vec2 TopLeft Top left corner in world coordinates.
---@return Vec2 BottomRight Bottom right corner in world coordinates. This pixel is not included in the final rectangle.
local function getBaseArea()
local xml = NXML.parse(ModTextFileGetContent("data/biome/_biomes_all.xml"))
local width, height = BiomeMapGetSize()
local offsetX, offsetY = math.floor(width/2), xml.attr.biome_offset_y -- TODO: This may not be right. Check what Noita is really doing when we have a biome map with an odd width.
return Vec2(-offsetX, -offsetY)*CHUNK_SIZE, Vec2(-offsetX+width, -offsetY+height)*CHUNK_SIZE
--return Vec2(-17920, -7168), Vec2(17920, 17408) -- Coordinates for a "New Game" without mods or anything.
end
---A list of capture areas.
---This contains functions that determine the capture area based on the biome size and other parameters.
---The returned vectors are the top left corner, and the bottom right corner of the capture area in world coordinates.
---The bottom right corner pixel is not included in the rectangle.
---@type table<string, fun():Vec2, Vec2>
Config.CaptureArea = {
-- Base layout: Every part outside this is based on a similar layout, but uses different materials/seeds.
["1x1"] = 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.
["1x3"] = function()
local width, height = BiomeMapGetSize()
local topLeft, bottomRight = getBaseArea()
return topLeft + Vec2(0, -height)*CHUNK_SIZE, bottomRight + Vec2(0, height)*CHUNK_SIZE
--return Vec2(-17920, -31744), Vec2(17920, 41984) -- Coordinates for a "New Game" without mods or anything.
end,
-- -1 parallel world: The parallel world with 3 parts: sky, normal and hell.
["1x3 -1"] = function()
local width, height = BiomeMapGetSize()
local topLeft, bottomRight = getBaseArea()
return topLeft + Vec2(-width, -height)*CHUNK_SIZE, bottomRight + Vec2(-width, height)*CHUNK_SIZE
--return Vec2(-17920, -31744) + Vec2(-35840, 0), Vec2(17920, 41984) + Vec2(-35840, 0) -- Coordinates for a "New Game" without mods or anything.
end,
-- +1 parallel world: The parallel world with 3 parts: sky, normal and hell.
["1x3 +1"] = function()
local width, height = BiomeMapGetSize()
local topLeft, bottomRight = getBaseArea()
return topLeft + Vec2(width, -height)*CHUNK_SIZE, bottomRight + Vec2(width, height)*CHUNK_SIZE
--return Vec2(-17920, -31744) + Vec2(35840, 0), Vec2(17920, 41984) + Vec2(35840, 0) -- Coordinates for a "New Game" without mods or anything.
end,
["1x3"] = {
TopLeft = Vec2(-17920, -31744), -- in world coordinates.
BottomRight = Vec2(17920, 41984), -- in world coordinates. This pixel is not included in the rectangle.
},
-- Extended: Main world + a fraction of the parallel worlds to the left and right.
["1.5x3"] = function()
local width, height = BiomeMapGetSize()
local topLeft, bottomRight = getBaseArea()
return topLeft + Vec2(-math.floor(0.25*width), -height)*CHUNK_SIZE, bottomRight + Vec2(math.floor(0.25*width), height)*CHUNK_SIZE
--return Vec2(-25600, -31744), Vec2(25600, 41984) -- Coordinates for a "New Game" without mods or anything. These coordinates may not exactly be 1.5 of the base width for historic reasons.
end,
-- Extended: Main world + each parallel world to the left and right.
["3x3"] = function()
local width, height = BiomeMapGetSize()
local topLeft, bottomRight = getBaseArea()
return topLeft + Vec2(-width, -height)*CHUNK_SIZE, bottomRight + Vec2(width, height)*CHUNK_SIZE
--return Vec2(-53760, -31744), Vec2(53760, 41984) -- Coordinates for a "New Game" without mods or anything.
end,
["1.5x3"] = {
TopLeft = Vec2(-25600, -31744), -- in world coordinates.
BottomRight = Vec2(25600, 41984), -- in world coordinates. This pixel is not included in the rectangle.
},
}

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.
-- https://opensource.org/licenses/MIT
@ -12,7 +12,6 @@
--------------------------
local Coords = require("coordinates")
local DebugAPI = require("noita-api.debug")
----------
-- Code --
@ -128,13 +127,12 @@ function Message:ShowWrongResolution(callback, desc)
"The resolution changed:",
desc or "",
" ",
"Press the button at the bottom to set up and close Noita automatically.",
" ",
"You can always reset any custom settings by right clicking the `start capture`",
"button at the top left.",
"To fix:",
"- Deselect and select the Noita window, or",
"- restart Noita or revert the resolution change."
},
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.
}
@ -188,53 +186,13 @@ end
function Message:ShowModificationUnsupported(realm, name, value)
self.List = self.List or {}
self.List["ModificationFailed"] = self.List["ModificationFailed"] or {
self.List["ModificationFailed"] = {
Type = "warning",
Lines = {
string.format("Couldn't modify %q in %q realm.", name, realm),
" ",
"This simply means that this modification is not supported for the Noita version you are using.",
"Feel free to open an issue at https://github.com/Dadido3/noita-mapcap.",
},
}
-- 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-2023 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
@ -188,86 +188,6 @@ function Modification.SetMemoryOptions(memory)
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] = {
@ -328,90 +248,13 @@ function Modification.SetMemoryOptions(memory)
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,
},
},
},
}
-- Look up the tree and set options accordingly.
local level1 = lookup[DebugAPI.IsDevBuild()]
local level1 = lookup[DebugGetIsDevBuild()]
level1 = level1 or {}
local level2 = level1[ffi.os]
@ -448,13 +291,6 @@ function Modification.PatchFiles(patches)
end
ModTextFileSetContent("data/shaders/post_final.frag", postFinal)
end
if patches.PostFinalReplace then
local postFinal = ModTextFileGetContent("data/shaders/post_final.frag")
for k, v in pairs(patches.PostFinalReplace) do
postFinal = postFinal:gsub(k, v)
end
ModTextFileSetContent("data/shaders/post_final.frag", postFinal)
end
end
---Returns tables with user requested game configuration changes.
@ -476,16 +312,12 @@ function Modification.RequiredChanges()
config["internal_size_h"] = tostring(Vec2(ModSettingGet("noita-mapcap.internal-resolution")).y)
config["backbuffer_width"] = config["window_w"]
config["backbuffer_height"] = config["window_h"]
config["fullscreen"] = "0"
magic["VIRTUAL_RESOLUTION_X"] = tostring(Vec2(ModSettingGet("noita-mapcap.virtual-resolution")).x)
magic["VIRTUAL_RESOLUTION_Y"] = tostring(Vec2(ModSettingGet("noita-mapcap.virtual-resolution")).y)
-- Set virtual offset to prevent/reduce not correctly drawn pixels at the window border.
magic["GRID_RENDER_BORDER"] = "3" -- This will widen the right side of the virtual rectangle. It also shifts the world coordinates to the right.
magic["VIRTUAL_RESOLUTION_OFFSET_X"] = "-3"
magic["VIRTUAL_RESOLUTION_OFFSET_Y"] = "0"
magic["STREAMING_CHUNK_TARGET"] = "16" -- Keep more chunks alive.
magic["GRID_MAX_UPDATES_PER_FRAME"] = "1024" -- Allow more pixel physics simulation steps (in 32x32 regions) per frame. With too few, objects can glitch through the terrain/explode.
magic["GRID_MIN_UPDATES_PER_FRAME"] = "0" -- Also allow no updates.
else
-- Reset some values if there is no custom resolution requested.
config["internal_size_w"] = "1280"
@ -497,12 +329,13 @@ function Modification.RequiredChanges()
magic["VIRTUAL_RESOLUTION_OFFSET_Y"] = "-1"
end
-- Always expect a fullscreen mode of 0 (windowed).
-- Capturing will not work in fullscreen.
config["fullscreen"] = "0"
-- Also disable screen shake.
config["screenshake_intensity"] = "0"
-- And disable the cursor being rendered by Noita itself, which would make it appear in screen captures.
config["application_rendered_cursor"] = "0"
magic["DRAW_PARALLAX_BACKGROUND"] = ModSettingGet("noita-mapcap.disable-background") and "0" or "1"
-- These magic numbers seem only to work in the dev build.
@ -528,24 +361,13 @@ function Modification.RequiredChanges()
FOG_FOREGROUND_NIGHT = "vec4(0.0,0.0,0.0,1.0)",
FOG_BACKGROUND_NIGHT = "vec3(0.0,0.0,0.0)",
}
-- Disable color grading, which may make the world look a tad more blue when there is freezing/snowing weather.
-- This is dependent on the seed and the PC's wall clock, and it only snows in December, January or February.
patches.PostFinalReplace = {
["color%.rgb = mix%( color, additive_overlay_color%.rgb, additive_overlay_color%.a %);"] = "",
["color = mix%(color, vec3%(%(color%.r %+ color%.g %+ color%.b%) %* 0%.3333%), color_grading%.a%);"] = "",
["color = color %* color_grading%.rgb;"] = "// Here lies the remains of the tone-mapping/color grading code. 2019-2024. RIP",
}
end
if ModSettingGet("noita-mapcap.disable-shaders-gui-ai") and DebugAPI.IsDevBuild() then
memory["mPostFxDisabled"] = 1
memory["mGuiDisabled"] = 1
memory["mFreezeAI"] = 1
end
if DebugAPI.IsDevBuild() and magic["DEBUG_PAUSE_GRID_UPDATE"] == "1" then
memory["mTrailerMode"] = 1 -- This is necessary for chunks to correctly load when DEBUG_PAUSE_GRID_UPDATE is enabled.
memory["mTrailerMode"] = 1 -- 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

12
go.mod
View File

@ -1,10 +1,10 @@
module github.com/Dadido3/noita-mapcap
go 1.22
go 1.21
require (
github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1
github.com/Dadido3/go-libwebp v0.3.0
github.com/chai2010/webp v1.1.1
github.com/cheggaaa/pb/v3 v3.1.4
github.com/coreos/go-semver v0.3.1
github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237
@ -35,9 +35,9 @@ require (
github.com/rivo/uniseg v0.4.4 // indirect
github.com/tdewolff/minify/v2 v2.20.10 // indirect
github.com/tdewolff/parse/v2 v2.7.7 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/image v0.14.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
star-tex.org/x/tex v0.4.0 // indirect
)

20
go.sum
View File

@ -4,8 +4,6 @@ github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1 h1:LejjvYg4tCW5HO
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/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f/go.mod h1:vIOkSdX3NDCPwgu8FIuTat2zDF0FPXXQ0RYFRy+oQic=
github.com/Dadido3/go-libwebp v0.3.0 h1:Qr3Gt8Kn4qgemezDVnjAJffMB9C0QJhxP+9u0U5mC94=
github.com/Dadido3/go-libwebp v0.3.0/go.mod h1:rYiWwlI58XRSMUFMw23nMezErbjX3Z5Xv0Kk3w6Mwwo=
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
@ -18,6 +16,8 @@ github.com/benoitkugler/textprocessing v0.0.3 h1:Q2X+Z6vxuW5Bxn1R9RaNt0qcprBfpc2
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/chai2010/webp v1.1.1 h1:jTRmEccAJ4MGrhFOrPMpNGIJ/eybIgwKpcACsrTEapk=
github.com/chai2010/webp v1.1.1/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
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=
@ -87,11 +87,11 @@ github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4A
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/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
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/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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=
@ -99,14 +99,14 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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=

View File

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

View File

@ -1,4 +1,4 @@
-- Copyright (c) 2022-2024 David Vogel
-- Copyright (c) 2022-2023 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
@ -67,9 +67,9 @@ modSettings = {
{
id = "capture-mode",
ui_name = "Mode",
ui_description = "How the mod captures:\n- Live: Capture as you play along.\n- Area: Capture a defined area of the world.\n- Spiral: Capture in a spiral around a starting point indefinitely.\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",
values = { { "live", "Live" }, { "area", "Area" }, { "spiral", "Spiral" }, { "animation", "Animation"} },
values = { { "live", "Live" }, { "area", "Area" }, { "spiral", "Spiral" } },
scope = MOD_SETTING_SCOPE_RUNTIME,
},
{
@ -95,7 +95,7 @@ modSettings = {
ui_name = " Rectangle",
ui_description = "The area to be captured.\nSee documentation for more information.",
value_default = "1x1",
values = { { "1x1", "Base layout" }, { "1x3", "Main World" }, { "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,
show_fn = function() return modSettings:GetNextValue("capture-mode") == "area" end,
},
@ -143,7 +143,7 @@ modSettings = {
value_default = "512",
allowed_characters = "0123456789",
scope = MOD_SETTING_SCOPE_RUNTIME,
show_fn = function() return modSettings:GetNextValue("capture-mode") == "area" or modSettings:GetNextValue("capture-mode") == "spiral" end,
show_fn = function() return modSettings:GetNextValue("capture-mode") ~= "live" end,
},
{
id = "pixel-scale",
@ -157,18 +157,6 @@ modSettings = {
scope = MOD_SETTING_SCOPE_RUNTIME,
change_fn = roundChange,
},
{
id = "capture-delay",
ui_name = "Capture delay",
ui_description = "Additional delay before a screen capture is taken.\nThis can help the world to be populated, and the physics simulation to settle down.\nA setting of 0 means that the screenshot is taken as soon as possible.\n \nUse a value of 10 for a good result without slowing the capture process down too much.",
value_default = 0,
value_min = 0,
value_max = 60,
value_display_multiplier = 1,
value_display_formatting = " $0 frames",
scope = MOD_SETTING_SCOPE_RUNTIME,
show_fn = function() return modSettings:GetNextValue("capture-mode") == "area" or modSettings:GetNextValue("capture-mode") == "spiral" end,
},
{
id = "custom-resolution-live",
ui_name = "Use custom resolution",