Seregon/ShadPKG

A tool for deriving PKG packet encryption keys for ps4 written in c++

C++/47.3 KB/No license
gui/views/InspectorView.cpp
ShadPKG / gui / views / InspectorView.cpp
1// SPDX-FileCopyrightText: Copyright 2025 shadPKG
2// SPDX-License-Identifier: GPL-2.0-or-later
3 
4#include "../include/views/InspectorView.h"
5#include "../include/GuiLogSink.h"
6#include "../include/StyleManager.h"
7#include "common/assert.h"
8#include "core/file_format/psf.h"
9#include <algorithm>
10#include <assert.h>
11#ifndef IM_ASSERT
12#define IM_ASSERT(_EXPR) ASSERT(_EXPR)
13#endif
14#include "imgui.h"
15#include <cstring>
16#include <filesystem>
17#include <iomanip>
18#include <map>
19#include <sstream>
20 
21namespace ShadPKG::GUI {
22 
23// ╔═══════════════════════════════════════════════════════════════════════════╗
24// ║ InspectorView::Draw ║
25// ╚═══════════════════════════════════════════════════════════════════════════╝
26void InspectorView::Draw(GUIContext &ctx) {
27 // Sync with global context if a PKG was loaded elsewhere (e.g. Home)
28 if (ctx.pkgLoaded && !pkgLoaded_ && !ctx.loadedPkgPath.empty()) {
29 GuiLogSink::Instance().Info("Inspector: Syncing PKG from context: " +
30 ctx.loadedPkgPath);
31 strncpy(pkgPathBuf_, ctx.loadedPkgPath.c_str(), sizeof(pkgPathBuf_) - 1);
32 pkgPathBuf_[sizeof(pkgPathBuf_) - 1] = '\0';
33 LoadPkg(ctx, pkgPathBuf_);
34 
35 // Clear path from context to prevent re-syncing every frame
36 // But keep pkgLoaded true so we know *a* package is active globally
37 ctx.loadedPkgPath.clear();
38 }
39 
40 DrawLoadSection(ctx);
41 
42 ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 10));
43 
44 if (!pkgLoaded_) {
45 // Show placeholder when no PKG is loaded
46 ImVec2 center = ImGui::GetContentRegionAvail();
47 center.x = center.x / 2.0f;
48 center.y = center.y / 2.0f;
49 
50 ImGui::SetCursorPos(center);
51 ImGui::TextColored(Colors::TextDim,
52 ICON_FA_MAGNIFYING_GLASS " Load a PKG file to inspect");
53 ImGui::PopStyleVar();
54 return;
55 }
56 
57 ImGui::Spacing();
58 ImGui::Separator();
59 ImGui::Spacing();
60 
61 // Split view
62 ImVec2 avail = ImGui::GetContentRegionAvail();
63 float metadataWidth = avail.x * splitRatio_;
64 
65 // Left panel: Metadata
66 ImGui::BeginChild("##metadata_panel", ImVec2(metadataWidth - 5, avail.y),
67 true);
68 DrawMetadataPanel();
69 ImGui::EndChild();
70 
71 ImGui::SameLine();
72 
73 // Splitter
74 ImGui::InvisibleButton("##splitter", ImVec2(8, avail.y));
75 if (ImGui::IsItemHovered()) {
76 ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW);
77 }
78 if (ImGui::IsItemActive()) {
79 float delta = ImGui::GetIO().MouseDelta.x / avail.x;
80 splitRatio_ = std::clamp(splitRatio_ + delta, 0.2f, 0.6f);
81 }
82 
83 ImGui::SameLine();
84 
85 // Right panel: File system
86 ImGui::BeginChild("##filesystem_panel", ImVec2(0, avail.y), true);
87 DrawFilesystemPanel();
88 ImGui::EndChild();
89 
90 ImGui::PopStyleVar();
91}
92 
93// ┌─────────────────────────────────────────────────────────────────────────┐
94// │ Load Section │
95// └─────────────────────────────────────────────────────────────────────────┘
96void InspectorView::DrawLoadSection(GUIContext &ctx) {
97 ImGui::TextColored(Colors::Primary,
98 ICON_FA_MAGNIFYING_GLASS " PKG INSPECTOR");
99 ImGui::Spacing();
100 
101 ImGui::SetNextItemWidth(-100);
102 ImGui::InputTextWithHint("##inspectpath", "PKG file path...", pkgPathBuf_,
103 sizeof(pkgPathBuf_));
104 
105 ImGui::SameLine();
106 if (ImGui::Button("Load", ImVec2(90, 0))) {
107 if (strlen(pkgPathBuf_) > 0) {
108 LoadPkg(ctx, pkgPathBuf_);
109 }
110 }
111 
112 if (pkgLoaded_) {
113 ImGui::SameLine();
114 ImGui::TextColored(Colors::Success, ICON_FA_CHECK " %s", titleId_.c_str());
115 }
116}
117 
118// ┌─────────────────────────────────────────────────────────────────────────┐
119// │ Metadata Panel (SFO) │
120// └─────────────────────────────────────────────────────────────────────────┘
121void InspectorView::DrawMetadataPanel() {
122 ImGui::TextColored(Colors::Primary, "METADATA (SFO)");
123 ImGui::Spacing();
124 
125 if (sfoEntries_.empty()) {
126 ImGui::TextColored(Colors::TextDim, "No SFO data available");
127 return;
128 }
129 
130 // Table of SFO entries
131 if (ImGui::BeginTable("##sfo_table", 2,
132 ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
133 ImGuiTableFlags_Resizable |
134 ImGuiTableFlags_ScrollY)) {
135 
136 ImGui::TableSetupColumn("Key", ImGuiTableColumnFlags_WidthFixed, 120.0f);
137 ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthStretch);
138 ImGui::TableHeadersRow();
139 
140 for (const auto &entry : sfoEntries_) {
141 ImGui::TableNextRow();
142 
143 ImGui::TableNextColumn();
144 ImGui::TextColored(Colors::TextDim, "%s", entry.key.c_str());
145 
146 ImGui::TableNextColumn();
147 // Color special entries
148 if (entry.key == "TITLE_ID" || entry.key == "CONTENT_ID") {
149 ImGui::TextColored(Colors::Primary, "%s", entry.value.c_str());
150 } else {
151 ImGui::Text("%s", entry.value.c_str());
152 }
153 }
154 
155 ImGui::EndTable();
156 }
157}
158 
159// ┌─────────────────────────────────────────────────────────────────────────┐
160// │ Filesystem Panel (PFS) │
161// └─────────────────────────────────────────────────────────────────────────┘
162void InspectorView::DrawFilesystemPanel() {
163 ImGui::TextColored(Colors::Primary, "FILE SYSTEM (PFS)");
164 ImGui::Spacing();
165 
166 // Search filter
167 ImGui::SetNextItemWidth(-1);
168 ImGui::InputTextWithHint("##filefilter",
169 ICON_FA_MAGNIFYING_GLASS " Search files...",
170 searchFilter_, sizeof(searchFilter_));
171 ImGui::Spacing();
172 
173 if (!fileTreeLoaded_ || !fileTreeRoot_) {
174 if (pkgLoaded_) {
175 // Show load button for lazy loading
176 ImGui::TextColored(Colors::TextDim, "File tree not loaded.");
177 if (ImGui::Button("Load File List")) {
178 // Re-scan PKG to get file list
179 PKG pkg;
180 std::string failreason;
181 if (pkg.Scan(pkgPathBuf_, failreason)) {
182 LoadFileTree(pkg);
183 }
184 }
185 } else {
186 ImGui::TextColored(Colors::TextDim, "No PKG loaded");
187 }
188 return;
189 }
190 
191 // File tree table
192 if (ImGui::BeginTable("##filetree", 3,
193 ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
194 ImGuiTableFlags_Resizable |
195 ImGuiTableFlags_ScrollY)) {
196 
197 ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch);
198 ImGui::TableSetupColumn("Size", ImGuiTableColumnFlags_WidthFixed, 80.0f);
199 ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 50.0f);
200 ImGui::TableHeadersRow();
201 
202 // Draw root children
203 if (fileTreeRoot_) {
204 for (const auto &child : fileTreeRoot_->children) {
205 DrawFileNode(child.get());
206 }
207 }
208 
209 ImGui::EndTable();
210 }
211}
212 
213// ┌─────────────────────────────────────────────────────────────────────────┐
214// │ Draw single file/directory node │
215// └─────────────────────────────────────────────────────────────────────────┘
216void InspectorView::DrawFileNode(FileNode *node, int depth) {
217 if (!node)
218 return;
219 
220 const bool filterActive = strlen(searchFilter_) > 0;
221 if (filterActive && !NodeMatchesFilter(node)) {
222 return;
223 }
224 
225 ImGui::TableNextRow();
226 ImGui::TableNextColumn();
227 
228 // Push unique ID based on pointer address to avoid collisions
229 ImGui::PushID(node);
230 
231 const char *icon = GetFileIcon(node->name, node->isDirectory);
232 bool open = false;
233 ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_SpanFullWidth;
234 
235 if (node->isDirectory && !node->children.empty()) {
236 if (filterActive) {
237 ImGui::SetNextItemOpen(true, ImGuiCond_Always);
238 }
239 open = ImGui::TreeNodeEx(node->name.c_str(), flags, "%s %s", icon,
240 node->name.c_str());
241 } else {
242 // Leaf node
243 ImGui::TreeNodeEx(node->name.c_str(),
244 flags | ImGuiTreeNodeFlags_Leaf |
245 ImGuiTreeNodeFlags_NoTreePushOnOpen,
246 "%s %s", icon, node->name.c_str());
247 }
248 
249 // Context menu (must be after item)
250 if (ImGui::BeginPopupContextItem()) {
251 if (ImGui::MenuItem(ICON_FA_DOWNLOAD " Extract...")) {
252 ExtractNode(node);
253 }
254 ImGui::EndPopup();
255 }
256 
257 ImGui::TableNextColumn();
258 if (!node->isDirectory) {
259 ImGui::Text("%s", FormatSize(node->size).c_str());
260 } else {
261 ImGui::TextColored(Colors::TextDim, "-");
262 }
263 
264 ImGui::TableNextColumn();
265 ImGui::Text("%s", node->isDirectory ? "DIR" : "FILE");
266 
267 if (open) {
268 for (const auto &child : node->children) {
269 DrawFileNode(child.get(), depth + 1);
270 }
271 ImGui::TreePop();
272 }
273 
274 ImGui::PopID();
275}
276 
277// ┌─────────────────────────────────────────────────────────────────────────┐
278// │ Data Loading │
279// └─────────────────────────────────────────────────────────────────────────┘
280void InspectorView::LoadPkg(GUIContext &ctx, const std::string &path) {
281 Clear();
282 
283 strncpy(pkgPathBuf_, path.c_str(), sizeof(pkgPathBuf_) - 1);
284 pkgPathBuf_[sizeof(pkgPathBuf_) - 1] = '\0';
285 
286 auto pkg = std::make_shared<PKG>();
287 std::string failreason;
288 
289 // Use Open ensuring SFO is loaded
290 if (!pkg->Open(path, failreason)) {
291 GuiLogSink::Instance().Error("Failed to open PKG: " + failreason);
292 return;
293 }
294 
295 titleId_ = std::string(pkg->GetTitleID());
296 pkgSize_ = pkg->GetPkgSize();
297 pkgLoaded_ = true;
298 
299 // Load SFO data (Open populates this)
300 LoadSfoData(*pkg);
301 
302 // Use Scan to populate file table for PFS
303 if (pkg->Scan(path, failreason)) {
304 LoadFileTree(*pkg);
305 } else {
306 GuiLogSink::Instance().Warn("Failed to scan PKG filesystem: " + failreason);
307 }
308 
309 // Set shared instance
310 ctx.currentPkg = pkg;
311 
312 GuiLogSink::Instance().Info("PKG inspected: " + titleId_);
313}
314 
315void InspectorView::LoadSfoData(const PKG &pkg) {
316 sfoEntries_.clear();
317 
318 if (pkg.sfo.empty()) {
319 return;
320 }
321 
322 PSF psf;
323 // Need non-const access for PSF::Open
324 std::vector<u8> sfoData = pkg.sfo;
325 if (!psf.Open(sfoData)) {
326 GuiLogSink::Instance().Warn("Failed to parse param.sfo");
327 return;
328 }
329 
330 const auto &entries = psf.GetEntries();
331 for (const auto &entry : entries) {
332 SfoEntry sfoEntry;
333 sfoEntry.key = entry.key;
334 
335 switch (entry.param_fmt) {
336 case PSFEntryFmt::Text:
337 if (auto v = psf.GetString(entry.key); v.has_value()) {
338 sfoEntry.value = std::string(*v);
339 sfoEntry.type = "String";
340 
341 if (entry.key == "CONTENT_ID") {
342 contentId_ = sfoEntry.value;
343 }
344 }
345 break;
346 
347 case PSFEntryFmt::Integer:
348 if (auto v = psf.GetInteger(entry.key); v.has_value()) {
349 sfoEntry.value = std::to_string(*v);
350 sfoEntry.type = "Integer";
351 }
352 break;
353 
354 case PSFEntryFmt::Binary:
355 if (auto v = psf.GetBinary(entry.key); v.has_value()) {
356 std::ostringstream ss;
357 ss << "0x";
358 for (size_t i = 0; i < std::min(v->size(), size_t(8)); ++i) {
359 ss << std::hex << std::setfill('0') << std::setw(2)
360 << static_cast<int>((*v)[i]);
361 }
362 if (v->size() > 8)
363 ss << "...";
364 sfoEntry.value = ss.str();
365 sfoEntry.type = "Binary";
366 }
367 break;
368 }
369 
370 sfoEntries_.push_back(sfoEntry);
371 }
372}
373 
374void InspectorView::LoadFileTree(const PKG &pkg) {
375 auto entries = pkg.GetEntriesInfo();
376 BuildFileTree(entries);
377 fileTreeLoaded_ = true;
378}
379 
380void InspectorView::BuildFileTree(const std::vector<PKG::EntryInfo> &entries) {
381 fileTreeRoot_ = std::make_unique<FileNode>();
382 fileTreeRoot_->name = "/";
383 fileTreeRoot_->isDirectory = true;
384 
385 // Map for quick parent lookup
386 std::map<std::string, FileNode *> pathMap;
387 pathMap[""] = fileTreeRoot_.get();
388 pathMap["/"] = fileTreeRoot_.get();
389 
390 for (size_t i = 0; i < entries.size(); ++i) {
391 const auto &entry = entries[i];
392 std::string path = entry.path.empty() ? entry.name : entry.path;
393 
394 // Find or create parent directories
395 std::filesystem::path fsPath(path);
396 FileNode *parent = fileTreeRoot_.get();
397 
398 std::string accumulated;
399 for (auto it = fsPath.begin(); it != fsPath.end(); ++it) {
400 std::string component = it->string();
401 // Skip current/parent dir markers to avoid garbage entries
402 if (component.empty() || component == "/" || component == "." ||
403 component == "..")
404 continue;
405 
406 auto next = it;
407 ++next;
408 bool isLast = (next == fsPath.end());
409 
410 std::string newPath =
411 accumulated.empty() ? component : accumulated + "/" + component;
412 
413 if (isLast) {
414 // This is the actual entry
415 auto node = std::make_unique<FileNode>();
416 node->name = component;
417 node->fullPath = path;
418 node->size = entry.size;
419 node->isDirectory = (entry.type == 1); // PFS_DIR
420 if (!node->isDirectory) {
421 node->fileIndex = static_cast<int>(i);
422 }
423 
424 parent->children.push_back(std::move(node));
425 } else {
426 // Intermediate directory
427 auto it2 = pathMap.find(newPath);
428 if (it2 != pathMap.end()) {
429 parent = it2->second;
430 } else {
431 auto node = std::make_unique<FileNode>();
432 node->name = component;
433 node->fullPath = newPath;
434 node->isDirectory = true;
435 
436 FileNode *rawPtr = node.get();
437 parent->children.push_back(std::move(node));
438 pathMap[newPath] = rawPtr;
439 parent = rawPtr;
440 }
441 }
442 
443 accumulated = newPath;
444 }
445 }
446}
447 
448void InspectorView::Clear() {
449 pkgLoaded_ = false;
450 sfoEntries_.clear();
451 fileTreeRoot_.reset();
452 fileTreeLoaded_ = false;
453 titleId_.clear();
454 contentId_.clear();
455 pkgSize_ = 0;
456}
457 
458// ┌─────────────────────────────────────────────────────────────────────────┐
459// │ Helper Functions │
460// └─────────────────────────────────────────────────────────────────────────┘
461std::string InspectorView::FormatSize(uint64_t bytes) {
462 const char *units[] = {"B", "KB", "MB", "GB", "TB"};
463 int unitIndex = 0;
464 double size = static_cast<double>(bytes);
465 
466 while (size >= 1024.0 && unitIndex < 4) {
467 size /= 1024.0;
468 unitIndex++;
469 }
470 
471 std::ostringstream ss;
472 if (unitIndex == 0) {
473 ss << bytes << " " << units[unitIndex];
474 } else {
475 ss << std::fixed << std::setprecision(1) << size << " " << units[unitIndex];
476 }
477 return ss.str();
478}
479 
480const char *InspectorView::GetFileIcon(const std::string &name, bool isDir) {
481 if (isDir) {
482 return ICON_FA_FOLDER;
483 }
484 
485 // Get extension
486 std::filesystem::path p(name);
487 std::string ext = p.extension().string();
488 std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
489 
490 // Icon mapping
491 if (ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp" ||
492 ext == ".dds") {
493 return ICON_FA_IMAGE;
494 }
495 if (ext == ".sfo" || ext == ".json" || ext == ".xml" || ext == ".txt") {
496 return ICON_FA_FILE;
497 }
498 if (ext == ".pkg" || ext == ".zip" || ext == ".rar" || ext == ".7z") {
499 return ICON_FA_BOX_ARCHIVE;
500 }
501 
502 return ICON_FA_FILE;
503}
504 
505bool InspectorView::MatchesFilterText(const std::string &text) const {
506 if (strlen(searchFilter_) == 0)
507 return true;
508 
509 std::string lowerName = text;
510 std::string lowerFilter = searchFilter_;
511 std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(),
512 ::tolower);
513 std::transform(lowerFilter.begin(), lowerFilter.end(), lowerFilter.begin(),
514 ::tolower);
515 
516 return lowerName.find(lowerFilter) != std::string::npos;
517}
518 
519bool InspectorView::NodeMatchesFilter(const FileNode *node) const {
520 if (!node)
521 return false;
522 if (strlen(searchFilter_) == 0)
523 return true;
524 if (MatchesFilterText(node->name) || MatchesFilterText(node->fullPath)) {
525 return true;
526 }
527 if (node->isDirectory) {
528 for (const auto &child : node->children) {
529 if (NodeMatchesFilter(child.get())) {
530 return true;
531 }
532 }
533 }
534 return false;
535}
536 
537// ┌─────────────────────────────────────────────────────────────────────────┐
538// │ Extraction Logic │
539// └─────────────────────────────────────────────────────────────────────────┘
540void InspectorView::ExtractNode(const FileNode *node) {
541 if (!node)
542 return;
543 
544 std::string destFolder = PickFolder();
545 if (destFolder.empty())
546 return;
547 
548 // Re-open PKG to extract
549 PKG pkg;
550 std::string failreason;
551 
552 // We use Scan with extract_root to set the base path for extraction.
553 // This allows ExtractFiles(index) to reconstruct the full path correctly
554 // under our destination folder.
555 if (!pkg.Scan(pkgPathBuf_, failreason, destFolder)) {
556 GuiLogSink::Instance().Error("Extraction failed (Scan): " + failreason);
557 return;
558 }
559 
560 // Recursive extraction helper
561 std::function<void(const FileNode *)> extractRecursive =
562 [&](const FileNode *n) {
563 if (!n->isDirectory && n->fileIndex >= 0) {
564 pkg.ExtractFiles(n->fileIndex);
565 GuiLogSink::Instance().Info("Extracted: " + n->name);
566 } else if (n->isDirectory) {
567 for (const auto &child : n->children) {
568 extractRecursive(child.get());
569 }
570 }
571 };
572 
573 extractRecursive(node);
574 GuiLogSink::Instance().Info("Extraction complete.");
575}
576 
577std::string InspectorView::PickFolder() {
578 // Simple macOS folder picker using osascript
579 // In a real multi-platform app, use NFD or similar.
580 const char *cmd = "osascript -e 'POSIX path of (choose folder with prompt "
581 "\"Select Output Directory\")'";
582 FILE *pipe = popen(cmd, "r");
583 if (!pipe) {
584 GuiLogSink::Instance().Error("Failed to open folder picker.");
585 return "";
586 }
587 
588 char buffer[1024];
589 std::string result = "";
590 if (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
591 result = buffer;
592 // Remove newline
593 if (!result.empty() && result.back() == '\n') {
594 result.pop_back();
595 }
596 }
597 pclose(pipe);
598 return result;
599}
600 
601} // namespace ShadPKG::GUI
602