Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions THIRD_PARTY.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ or assign a blanket license to all files.
| Experimental rest-mode companion | [PS5Tailscale and pinned ps5debug-NG provenance](homebrew/rest-mode-companion/README.md#provenance) |
| cpp-httplib | [MIT notice](homebrew/botty/vendor/HTTPLIB-LICENSE) |
| nlohmann/json | [MIT notice](homebrew/botty/vendor/JSON-LICENSE) |
| stb_image | [MIT / public-domain notice](homebrew/botty/vendor/stb_image.h), pinned to nothings/stb `f0569113c93ad095470c54bf34a17b36646bbbb5`; used to decode local Switch icons |
| PS5 controller ABI | [SDL-derived header and zlib notice](homebrew/botty-native/vendor/NOTICE.md) |
| Manrope | [SIL Open Font License](homebrew/botty-native/assets/Manrope-OFL.txt) and vendored font source |
| SDK distributions | Component Dockerfiles and [native build environment](homebrew/botty-native/BUILD-ENVIRONMENT.json) |
Expand Down
11 changes: 11 additions & 0 deletions docs/EMULATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ Builds require complete corresponding sources, package validation and PS5 launch
Triangle cycles Games, Updates / DLC and Available files. Cross plays a library
game or copies a completed download into the game library. Completed files use one Analyze and import action; their verified metadata determines whether they are a game, update or DLC. Originals are preserved and name collisions are refused.
Eden determines title compatibility and applies compatible updates/DLC on launch.
Games show the title and base display version from their Control/NACP data,
embedded artwork, associated updates and DLC, and named mods with their saved
on/off switches. The selected game's panel includes its title ID and filename.
Legacy NSP/XCI files copied manually are inspected too; display metadata is
cached by file identity and private-key revision, without changing game files.
The display scan reads CNMT and Control data with bounded reads and does not
rehash entire games or confer import verification. Numeric CNMT revisions are
labelled as revisions when a readable NACP version is unavailable. Update/DLC
presence is distinct from the engine's compatibility/activation decision at launch;
mod switches do not establish build compatibility. Unreadable metadata keeps the
file visible with an explicit unavailable state.
Square opens a Switch search using the existing PS5/server download choices.
Automatic Switch downloads extract, verify and classify content before importing it into Emulation, on the PS5 or dedicated server. Download-only mode remains available.

Expand Down
14 changes: 11 additions & 3 deletions homebrew/botty-native/src/catalog.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "catalog.hpp"
#include <algorithm>
#include <cstdio>
namespace botty {
namespace {
Expand Down Expand Up @@ -30,14 +31,14 @@ struct JSON {
std::string_view field(std::string_view object,std::string_view key){JSON j{object};if(!j.take('{'))return {};if(j.take('}'))return {};
do{j.ws();auto k=j.p;if(!j.string())return {};auto name=slice(object,k+1,j.p-k-2);if(!j.take(':'))return {};j.ws();auto start=j.p;if(!j.value())return {};if(name==key)return slice(object,start,j.p-start);}while(j.take(','));return {};}
unsigned hex(char c){return c<='9'?c-'0':(c|32)-'a'+10;}
template<std::size_t N> bool decode(std::string_view s,std::array<char,N>& out,bool exact=false){out.fill(0);if(s.size()<2||s.front()!='"')return false;unsigned at=0;bool fits=true;
template<std::size_t N> bool decode(std::string_view s,std::array<char,N>& out,bool exact=false,bool multiline=false){out.fill(0);if(s.size()<2||s.front()!='"')return false;unsigned at=0;bool fits=true;
auto put=[&](unsigned c){if(c==0)fits=false;if(at+1<N)out[at++]=static_cast<char>(c);else fits=false;};
for(std::size_t i=1;i+1<s.size();++i){unsigned char c=s[i];if(c!='\\'){put(c);continue;}c=s[++i];
if(c=='u'){unsigned cp=0;for(unsigned n=0;n<4;++n)cp=cp*16+hex(s[++i]);
if(cp>=0xd800&&cp<=0xdbff&&i+6<s.size()&&s[i+1]=='\\'&&s[i+2]=='u'){i+=2;unsigned low=0;for(unsigned n=0;n<4;++n)low=low*16+hex(s[++i]);cp=low>=0xdc00&&low<=0xdfff?0x10000+((cp-0xd800)<<10)+low-0xdc00:0xfffd;}
if(cp>=0xd800&&cp<=0xdfff)cp=0xfffd;
if(cp<128)put(cp<32&&!exact?' ':cp);else if(cp<2048){put(0xc0|(cp>>6));put(0x80|(cp&63));}else if(cp<65536){put(0xe0|(cp>>12));put(0x80|((cp>>6)&63));put(0x80|(cp&63));}else{put(0xf0|(cp>>18));put(0x80|((cp>>12)&63));put(0x80|((cp>>6)&63));put(0x80|(cp&63));}
}else {unsigned d=c=='n'?'\n':c=='r'?'\r':c=='t'?'\t':c=='b'?'\b':c=='f'?'\f':c;put(d<32&&!exact?' ':d);}
if(cp<128)put(cp<32&&!exact&&!(multiline&&cp=='\n')?' ':cp);else if(cp<2048){put(0xc0|(cp>>6));put(0x80|(cp&63));}else if(cp<65536){put(0xe0|(cp>>12));put(0x80|((cp>>6)&63));put(0x80|(cp&63));}else{put(0xf0|(cp>>18));put(0x80|((cp>>12)&63));put(0x80|((cp>>6)&63));put(0x80|(cp&63));}
}else {unsigned d=c=='n'?'\n':c=='r'?'\r':c=='t'?'\t':c=='b'?'\b':c=='f'?'\f':c;put(d<32&&!exact&&!(multiline&&d=='\n')?' ':d);}
}
if(!fits&&!exact){out[N-4]='.';out[N-3]='.';out[N-2]='.';}
return fits;
Expand Down Expand Up @@ -130,6 +131,13 @@ bool parseCatalog(std::string_view body,Catalog& out) noexcept {
auto& e=out.emulationEntries[out.emulationCount++];e=Entry{};decode(field(row,"phase"),e.phase);
if(!decode(field(row,"id"),e.id,true)||!decode(field(row,"kind"),e.kind,true))return false;
decode(field(row,"name"),e.name);decode(field(row,"path"),e.destination);e.total=number(field(row,"size"));
decode(field(row,"titleId"),e.titleId);decode(field(row,"details"),e.files,false,true);decode(field(row,"metadataStatus"),e.error);
decode(field(row,"baseVersion"),e.emulation.baseVersion);decode(field(row,"updateVersion"),e.emulation.updateVersion);
decode(field(row,"artworkId"),e.emulation.artwork);
decode(field(row,"modSummary"),e.emulation.mods,false,true);
e.emulation.dlcCount=static_cast<unsigned>(std::clamp(number(field(row,"dlcCount")),0.0,4096.0));
e.emulation.modCount=static_cast<unsigned>(std::clamp(number(field(row,"modCount")),0.0,4096.0));
e.emulation.modsOn=static_cast<unsigned>(std::clamp(number(field(row,"modsOn")),0.0,4096.0));e.emulation.modsKnown=field(row,"modsKnown")=="true";
}while(items.take(','));if(!items.take(']'))return false;}
}
out.nativeUpdate=NativeUpdate{};
Expand Down
8 changes: 8 additions & 0 deletions homebrew/botty-native/src/catalog.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@
#include "model.hpp"
#include <array>
namespace botty {
struct EmulationDetails {
std::array<char,96> artwork{};
std::array<char,64> baseVersion{},updateVersion{};
std::array<char,512> mods{};
unsigned dlcCount=0,modCount=0,modsOn=0;
bool modsKnown=false;
};
struct Entry {
EmulationDetails emulation;
std::array<char,96> id{};
std::array<char,64> hash{};int torrentId=-1;
std::array<char,64> storage{},sourceStorage{},compressedStorage{};
Expand Down
41 changes: 36 additions & 5 deletions homebrew/botty-native/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ std::array<char,96> artworkId(const std::array<char,96>& entryId,char source) no
std::array<char,96> id{};
std::snprintf(id.data(),id.size(),"%c:%.93s",source,entryId.data());return id;
}
std::array<char,96> artworkId(const botty::Entry& entry,char source) noexcept {return artworkId(entry.id,source);}
std::array<char,96> artworkId(const botty::Entry& entry,char source) noexcept {return artworkId(source=='m'&&entry.emulation.artwork[0]?entry.emulation.artwork:entry.id,source);}
// Cover of the game a Get game sheet opened for: Explore ids are raw, search ids prefixed.
std::array<char,96> sheetCover() noexcept {return artworkId(workflow.gameId,workflow.command.operation==botty::Operation::grab?'s':'e');}
const std::array<unsigned char,160*240*3>* coverFor(const std::array<char,96>& id) noexcept {
Expand Down Expand Up @@ -1249,10 +1249,37 @@ void drawEmulation(Canvas& c) noexcept {
for(unsigned i=0;i<6&&first+i<model.count;++i){
const auto* e=botty::emulationAt(catalog,model.filter,first+i);if(!e)break;
const unsigned y=300+i*102;const bool focused=first+i==model.selected;
c.rounded(96,y,1728,88,12,focused?raised:surface);
if(focused)focusRing(c,96,y,1728,88,12,3,bg);
fit(c,120,y+14,e->name.data(),28,34,1400,ink,bold);
char size[48];botty::formatBytes(e->total,size,sizeof(size));fit(c,120,y+52,e->phase[0]?e->phase.data():size,20,24,1680,muted);
c.rounded(96,y,1056,88,12,focused?raised:surface);
if(focused)focusRing(c,96,y,1056,88,12,3,bg);
art(c,108,y+5,52,78,6,artworkId(*e,'m'),e->name.data(),i,focused?raised:surface);
fit(c,180,y+12,e->name.data(),27,34,948,ink,bold);
char size[48];botty::formatBytes(e->total,size,sizeof(size));fit(c,180,y+50,e->phase[0]?e->phase.data():size,19,24,948,muted);
}
if(const auto* selected=botty::emulationAt(catalog,model.filter,model.selected)){
c.rounded(1176,300,648,598,16,surface);
art(c,1200,324,128,192,10,artworkId(*selected,'m'),selected->name.data(),model.selected,surface);
wrap(c,1352,324,selected->name.data(),30,38,448,3,ink,bold);
fit(c,1352,450,selected->titleId.data(),18,24,448,muted);
char size[48];botty::formatBytes(selected->total,size,sizeof(size));fit(c,1352,486,size,22,28,448,muted);
c.rounded(1200,540,600,1,0,line);
const auto& info=selected->emulation;
if(info.baseVersion[0]){
text(c,1200,560,"BASE VERSION",17,24,muted,bold);
fit(c,1200,590,info.baseVersion.data(),28,34,276,ink,bold);
text(c,1500,560,"UPDATE PRESENT",17,24,muted,bold);
fit(c,1500,590,info.updateVersion.data(),28,34,300,ink,bold);
c.rounded(1200,642,284,76,10,raised);c.rounded(1496,642,304,76,10,raised);
text(c,1216,652,"DLC",17,22,muted,bold);text(c,1512,652,"MODS",17,22,muted,bold);
char dlc[64],mods[64];std::snprintf(dlc,sizeof(dlc),"%u detected",info.dlcCount);
if(!info.modsKnown)std::snprintf(mods,sizeof(mods),"Status unavailable");
else if(!info.modCount)std::snprintf(mods,sizeof(mods),"None detected");
else std::snprintf(mods,sizeof(mods),"%u of %u switched on",info.modsOn,info.modCount);
fit(c,1216,680,dlc,23,28,252,ink,bold);fit(c,1512,680,mods,23,28,272,ink,bold);
if(info.mods[0])wrap(c,1200,740,info.mods.data(),20,26,600,3,muted);
else wrap(c,1200,740,"Triangle opens the update and DLC files. Options opens this game's settings.",20,26,600,3,muted);
const std::string_view path=selected->destination.data();const auto slash=path.find_last_of('/');
fit(c,1200,850,slash==std::string_view::npos?path:botty::slice(path,slash+1,path.size()-slash-1),17,22,600,faint);
}else wrap(c,1200,566,selected->files[0]?selected->files.data():selected->phase.data(),20,27,600,10,ink);
}
unsigned hx=96;
if(model.filter==0){hx=hint(c,hx,Glyph::cross,"Play",true);hx=hint(c,hx,Glyph::menu,"Game configuration");}
Expand Down Expand Up @@ -1521,6 +1548,10 @@ bool draw(Canvas& c) noexcept {
const unsigned first=(model.selected/Model::libraryColumns)*Model::libraryColumns;
for(unsigned i=0;i<botty::coverSlots&&first+i<model.count;++i){const auto* entry=botty::libraryAt(catalog,model.filter,first+i);if(entry){ids[i]=artworkId(*entry,'j');names[i]=entry->name.data();}}
}
if(model.tab==Model::emulation){
const unsigned first=(model.selected/6)*6;
for(unsigned i=0;i<6&&first+i<model.count;++i){const auto* entry=botty::emulationAt(catalog,model.filter,first+i);if(entry){ids[i]=artworkId(*entry,'m');names[i]=entry->name.data();}}
}
artwork.request(ids);if(artwork.read(covers))++displayRevision;
#ifdef BOTTY_HOST_PREVIEW
// Cover fixtures are matched by title, so a cover never lands on another game:
Expand Down
7 changes: 6 additions & 1 deletion homebrew/botty-native/tests/model_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -482,10 +482,15 @@ int main() {
// Start in Discover: Explore and Search share one tab.
{
auto edenCatalog=std::make_unique<Catalog>();
const std::string edenState=R"({"torrents":[],"jobs":[],"transmissionReady":true,"freeBytes":1,"emulation":{"supported":true,"installed":true,"entries":[{"id":")"+std::string(64,'a')+R"(","kind":"roms","name":"Game","path":"/data/prosperoeden/roms/Game.xci","size":512}]}})";
const std::string edenState=R"({"torrents":[],"jobs":[],"transmissionReady":true,"freeBytes":1,"emulation":{"supported":true,"installed":true,"entries":[{"id":")"+std::string(64,'a')+R"json(","kind":"roms","name":"Game","path":"/data/prosperoeden/roms/Game.xci","size":512,"titleId":"0100000000000000","baseVersion":"v1.0.0","updateVersion":"v3.0.5","dlcCount":1,"modCount":2,"modsOn":1,"modsKnown":true,"modSummary":"First mod (on)\nSecond mod (off)","phase":"Base v1.0.0 | Update v3.0.5 present | DLC 1 | Mods 1/2 on","details":"Base: v1.0.0\nUpdate present: v3.0.5\nMods: UltraCam (off)"}]}})json";
assert(parseCatalog(edenState,*edenCatalog));assert(edenCatalog->emulationSupported);
assert(emulationCount(*edenCatalog,0)==1&&emulationCount(*edenCatalog,1)==0);
assert(std::string(emulationAt(*edenCatalog,0,0)->name.data())=="Game");
assert(std::string(emulationAt(*edenCatalog,0,0)->titleId.data())=="0100000000000000");
assert(std::string(emulationAt(*edenCatalog,0,0)->files.data()).find("\n")!=std::string::npos);
assert(std::string(emulationAt(*edenCatalog,0,0)->emulation.baseVersion.data())=="v1.0.0");
assert(emulationAt(*edenCatalog,0,0)->emulation.modsOn==1);
assert(std::string(emulationAt(*edenCatalog,0,0)->emulation.mods.data()).find("\nSecond mod (off)")!=std::string::npos);
edenCatalog->storageSupported=true;edenCatalog->remoteEnabled=true;
Workflow edenDownload;Entry release;std::fill_n(release.id.begin(),32,'b');
edenDownload.grab(release,*edenCatalog,true);
Expand Down
8 changes: 4 additions & 4 deletions homebrew/botty/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ build/native/sha1.o build/ps5/sha1.o: $(UNRAR)/sha1.hpp $(UNRAR)/rartypes.hpp
build/ps5/%.o: $(UNRAR)/%.cpp $(UNRAR)/global.hpp $(UNRAR)/unpack30.cpp
@mkdir -p build/ps5
$(PS5_CXX) $(FLAGS) $(RAR_FLAGS) -DPS5_PAYLOAD -c $< -o $@
build/botty-native: build/host/server.o build/host/core.o build/host/storage.o build/host/rar.o $(NATIVE_OBJ) $(UPDATER_HEADERS)
build/botty-native: build/host/server.o build/host/core.o build/host/storage.o build/host/rar.o build/host/switch-artwork.o $(NATIVE_OBJ) $(UPDATER_HEADERS)
$(NATIVE_CXX) $(APP_FLAGS) $(filter %.cpp %.o,$^) -o $@ -lcurl -lcrypto -lz
build/botty-manager.elf: src/server.cpp src/core.cpp src/storage.cpp src/rar.cpp $(PS5_OBJ) $(UPDATER_HEADERS) build/service-updater-embedded.hpp
build/botty-manager.elf: src/server.cpp src/core.cpp src/storage.cpp src/rar.cpp src/switch-artwork.cpp $(PS5_OBJ) $(UPDATER_HEADERS) build/service-updater-embedded.hpp
$(PS5_CXX) $(APP_FLAGS) -Os -D__PS5__ $(filter %.cpp %.o,$^) -o $@ -lkernel_sys -lcurl -lpsl -lssl -lcrypto -lzstd -lz
build/service-updater.elf: src/service-updater.cpp $(UPDATER_HEADERS) build/ps5/sha256.o
$(PS5_CXX) $(APP_FLAGS) -D__PS5__ $(filter %.cpp %.o,$^) -o $@ -lkernel_sys -lcurl -lpsl -lssl -lcrypto -lzstd -lz
Expand Down Expand Up @@ -65,7 +65,7 @@ test-build: $(TEST_BINARIES)
# Each `test` command is its own target so TEST_JOBS can run them concurrently
# (tests use temporary directories and ephemeral ports). Longest runs start first.
SERVICE_UNIT_TESTS := switch-settlement-test emulation-test remote-buffer-test progress-test core-test rest-mode-test rest-companion-test rest-network-test rest-http-test native-transaction-test native-update-test native-idle-test installation-update-test installation-runtime-test search-idle-test storage-test
SERVICE_INTEGRATION_TESTS := switch_pipeline emulation_integration remote_integration search_integration storage_integration integration rtorrent_resume background_storage_integration
SERVICE_INTEGRATION_TESTS := switch_pipeline emulation_integration emulation_library remote_integration search_integration storage_integration integration rtorrent_resume background_storage_integration
UNIT_RUNS := $(addprefix unit-,$(SERVICE_UNIT_TESTS))
INTEGRATION_RUNS := $(addprefix integration-,$(SERVICE_INTEGRATION_TESTS))
.PHONY: $(UNIT_RUNS) $(INTEGRATION_RUNS) integration-remote-bridge
Expand Down Expand Up @@ -171,7 +171,7 @@ test-rest-network: build/rest-network-test build/rest-http-test
build/rest-companion-test: build/tests/rest-companion-test.o src/rest-companion.hpp src/rest-companion-protocol.h build/host/core.o build/host/storage.o build/host/rar.o $(NATIVE_OBJ)
$(NATIVE_CXX) $(FLAGS) $(filter %.o,$^) -o $@ -lcurl -lcrypto -lz

build/emulation-test: build/tests/emulation-test.o build/host/core.o build/host/storage.o $(NATIVE_OBJ)
build/emulation-test: build/tests/emulation-test.o build/host/core.o build/host/storage.o build/host/switch-artwork.o $(NATIVE_OBJ)
$(NATIVE_CXX) $(APP_FLAGS) $(filter %.o,$^) -o $@ -lcurl -lcrypto -lz

# Shared archive/container preparation runs natively on the server and PS5.
Expand Down
3 changes: 3 additions & 0 deletions homebrew/botty/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ transfers and simultaneous gameplay remain unvalidated on hardware.
`vendor/HTTPLIB-LICENSE`. PS5 compatibility uses `accept` instead of `accept4`.
- nlohmann/json v3.11.3, commit `9cca280a4d0ccf0c08f47a99aa71d1b0e52f8d03`;
`vendor/JSON-LICENSE`.
- stb_image, nothings/stb commit `f0569113c93ad095470c54bf34a17b36646bbbb5`;
MIT / public-domain license in `vendor/stb_image.h`. Only JPEG and TGA are
enabled for local Switch artwork; input size and image dimensions are bounded.
- Historical launcher integration derives from `ps5-payload-dev/websrv` v0.33,
commit `baabe27e5449baeb059b850d0393c31fdee219b7`.

Expand Down
Loading